@tolle_/tolle-ui 18.2.24 → 18.2.25
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/esm2022/lib/accordion-item.component.mjs +17 -5
- package/esm2022/lib/alert-dialog-dynamic.component.mjs +1 -1
- package/esm2022/lib/alert-dialog.component.mjs +48 -10
- package/esm2022/lib/alert-dialog.service.mjs +13 -2
- package/esm2022/lib/alert.component.mjs +5 -5
- package/esm2022/lib/avatar.component.mjs +30 -9
- package/esm2022/lib/badge.component.mjs +41 -15
- package/esm2022/lib/breadcrumb-link.component.mjs +5 -5
- package/esm2022/lib/button.component.mjs +14 -3
- package/esm2022/lib/calendar.component.mjs +99 -17
- package/esm2022/lib/card.component.mjs +29 -9
- package/esm2022/lib/carousel/carousel.component.mjs +19 -3
- package/esm2022/lib/checkbox.component.mjs +44 -21
- package/esm2022/lib/collapsible.component.mjs +12 -4
- package/esm2022/lib/country-selector.component.mjs +140 -20
- package/esm2022/lib/data-table.component.mjs +27 -16
- package/esm2022/lib/date-picker.component.mjs +24 -4
- package/esm2022/lib/date-range-picker.component.mjs +37 -9
- package/esm2022/lib/dropdown-item.component.mjs +19 -5
- package/esm2022/lib/dropdown-label.component.mjs +3 -3
- package/esm2022/lib/dropdown-menu.component.mjs +3 -3
- package/esm2022/lib/dropdown-separator.component.mjs +3 -3
- package/esm2022/lib/dropdown-trigger.directive.mjs +7 -3
- package/esm2022/lib/empty-state.component.mjs +3 -3
- package/esm2022/lib/hover-card.component.mjs +4 -2
- package/esm2022/lib/input.component.mjs +7 -5
- package/esm2022/lib/label.component.mjs +2 -2
- package/esm2022/lib/masked-input.component.mjs +3 -3
- package/esm2022/lib/modal.component.mjs +58 -22
- package/esm2022/lib/modal.service.mjs +13 -1
- package/esm2022/lib/multi-select.component.mjs +228 -20
- package/esm2022/lib/otp.component.mjs +6 -3
- package/esm2022/lib/pagination.component.mjs +19 -9
- package/esm2022/lib/progress.component.mjs +8 -5
- package/esm2022/lib/radio-group.component.mjs +51 -3
- package/esm2022/lib/radio-item.component.mjs +46 -8
- package/esm2022/lib/radio-service.mjs +60 -1
- package/esm2022/lib/range-calendar.component.mjs +317 -98
- package/esm2022/lib/resizable-panel.component.mjs +5 -4
- package/esm2022/lib/scroll-area.component.mjs +5 -5
- package/esm2022/lib/segment.component.mjs +73 -7
- package/esm2022/lib/select-item.component.mjs +20 -2
- package/esm2022/lib/select-separator.component.mjs +3 -3
- package/esm2022/lib/select.component.mjs +221 -14
- package/esm2022/lib/separator.component.mjs +12 -6
- package/esm2022/lib/sheet-ref.mjs +8 -1
- package/esm2022/lib/sheet-wrapper.component.mjs +27 -11
- package/esm2022/lib/sheet.component.mjs +57 -16
- package/esm2022/lib/sheet.service.mjs +5 -1
- package/esm2022/lib/sidebar.component.mjs +73 -15
- package/esm2022/lib/skeleton.component.mjs +19 -5
- package/esm2022/lib/switch.component.mjs +59 -53
- package/esm2022/lib/tabs.component.mjs +75 -13
- package/esm2022/lib/tag-input.component.mjs +6 -3
- package/esm2022/lib/textarea.component.mjs +9 -7
- package/esm2022/lib/theme.service.mjs +114 -138
- package/esm2022/lib/toaster.component.mjs +19 -9
- package/esm2022/lib/toggle-group.component.mjs +76 -4
- package/esm2022/lib/tolle-config.mjs +1 -1
- package/esm2022/lib/tooltip.directive.mjs +41 -17
- package/esm2022/lib/utils/color.mjs +324 -0
- package/esm2022/public-api.mjs +2 -1
- package/fesm2022/tolle-ui.mjs +2645 -637
- package/fesm2022/tolle-ui.mjs.map +1 -1
- package/lib/alert-dialog.component.d.ts +10 -1
- package/lib/avatar.component.d.ts +16 -3
- package/lib/badge.component.d.ts +17 -5
- package/lib/button.component.d.ts +10 -3
- package/lib/calendar.component.d.ts +21 -3
- package/lib/card.component.d.ts +6 -0
- package/lib/carousel/carousel.component.d.ts +1 -0
- package/lib/checkbox.component.d.ts +12 -4
- package/lib/country-selector.component.d.ts +11 -2
- package/lib/date-picker.component.d.ts +1 -0
- package/lib/date-range-picker.component.d.ts +4 -1
- package/lib/dropdown-item.component.d.ts +4 -2
- package/lib/dropdown-trigger.directive.d.ts +1 -1
- package/lib/modal.component.d.ts +6 -0
- package/lib/multi-select.component.d.ts +20 -0
- package/lib/otp.component.d.ts +1 -0
- package/lib/progress.component.d.ts +1 -0
- package/lib/radio-group.component.d.ts +8 -0
- package/lib/radio-item.component.d.ts +10 -4
- package/lib/radio-service.d.ts +29 -0
- package/lib/range-calendar.component.d.ts +33 -4
- package/lib/segment.component.d.ts +13 -0
- package/lib/select-item.component.d.ts +4 -1
- package/lib/select.component.d.ts +20 -0
- package/lib/separator.component.d.ts +2 -0
- package/lib/sheet-wrapper.component.d.ts +4 -0
- package/lib/sheet.component.d.ts +10 -1
- package/lib/sidebar.component.d.ts +2 -0
- package/lib/skeleton.component.d.ts +9 -3
- package/lib/switch.component.d.ts +13 -20
- package/lib/tabs.component.d.ts +11 -1
- package/lib/theme.service.d.ts +21 -6
- package/lib/toaster.component.d.ts +2 -2
- package/lib/toggle-group.component.d.ts +20 -2
- package/lib/toggle.component.d.ts +1 -1
- package/lib/tolle-config.d.ts +8 -0
- package/lib/tooltip.directive.d.ts +3 -0
- package/lib/utils/color.d.ts +87 -0
- package/package.json +4 -1
- package/preset.js +28 -0
- package/public-api.d.ts +1 -0
- package/registry/docs-content.json +4214 -0
- package/registry/llms-full.txt +1599 -0
- package/registry/llms.txt +83 -0
- package/registry/manifest.json +4925 -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/avatar-fallback.json +16 -0
- package/registry/r/avatar.json +23 -0
- package/registry/r/badge.json +23 -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/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/checkbox.json +23 -0
- package/registry/r/collapsible.json +21 -0
- package/registry/r/context-menu-trigger.json +23 -0
- package/registry/r/context-menu.json +28 -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/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/hover-card.json +23 -0
- package/registry/r/input.json +21 -0
- package/registry/r/label.json +21 -0
- package/registry/r/masked-input.json +21 -0
- package/registry/r/modal.json +36 -0
- package/registry/r/multi-select.json +33 -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 +32 -0
- package/registry/r/phone-number-input.json +34 -0
- package/registry/r/popover-content.json +21 -0
- package/registry/r/popover.json +18 -0
- package/registry/r/progress.json +21 -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/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 +31 -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/sidebar.json +21 -0
- package/registry/r/skeleton.json +23 -0
- package/registry/r/slider.json +21 -0
- package/registry/r/switch.json +23 -0
- package/registry/r/tabs.json +23 -0
- package/registry/r/tag-input.json +25 -0
- package/registry/r/textarea.json +21 -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/tooltip.json +23 -0
- package/registry/registry.json +1540 -0
- package/theme.css +56 -1
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sheet-wrapper",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Sheet Wrapper",
|
|
5
|
+
"description": "Sheet Wrapper component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "sheet-ref.ts",
|
|
12
|
+
"target": "ui/sheet-ref.ts",
|
|
13
|
+
"content": "import { OverlayRef } from '@angular/cdk/overlay';\nimport { Subject } from 'rxjs';\nimport { SheetConfig } from './sheet.types';\n\nexport class SheetRef<R = any> {\n private readonly _afterClosed$ = new Subject<R | undefined | null>();\n afterClosed$ = this._afterClosed$.asObservable();\n\n constructor(\n private overlay: OverlayRef,\n public config: SheetConfig\n ) {\n if (this.config.hasBackdrop && this.config.backdropClose !== false) {\n this.overlay.backdropClick().subscribe(() => {\n this.close();\n });\n }\n\n // Escape-to-close, unless this is a blocking sheet (backdropClose === false).\n this.overlay.keydownEvents().subscribe((event: KeyboardEvent) => {\n if (event.key === 'Escape' && this.config.backdropClose !== false) {\n event.preventDefault();\n this.close();\n }\n });\n }\n\n /**\n * Closes the sheet and allows for the exit animation.\n * @param result Data to pass back to the caller\n */\n close(result?: R): void {\n // Notify the wrapper component to trigger exit animation if needed\n // In our service implementation, the wrapper handles [attr.data-state]\n // We just need to give it time to animate out before disposing\n\n // We'll update the state to 'closed' which triggers CSS animations\n // The service handles the actual disposal after a timeout\n this._afterClosed$.next(result);\n this._afterClosed$.complete();\n }\n\n /**\n * Internal use only: Disposes the overlay\n */\n dispose(): void {\n this.overlay.dispose();\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "sheet-wrapper.component.ts",
|
|
17
|
+
"target": "ui/sheet-wrapper.component.ts",
|
|
18
|
+
"content": "import { Component, OnInit, TemplateRef, Type, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { SheetRef } from './sheet-ref';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-sheet-wrapper',\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `\n <div\n [class]=\"computedClass\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"ref.config.title ? titleId : null\"\n [attr.aria-describedby]=\"ref.config.description ? descId : null\"\n cdkTrapFocus cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\"\n >\n <!-- Header -->\n <div *ngIf=\"ref.config.showCloseButton !== false || ref.config.title\" class=\"flex flex-col space-y-2 mb-4\">\n <div class=\"flex items-center justify-between\">\n <h3 *ngIf=\"ref.config.title\" [id]=\"titleId\" class=\"text-lg font-semibold text-foreground tracking-tight\">\n {{ ref.config.title }}\n </h3>\n <button \n *ngIf=\"ref.config.showCloseButton !== false\" \n (click)=\"close()\" \n class=\"ml-auto p-1 text-muted-foreground hover:text-foreground hover:bg-accent rounded-md transition-colors\"\n >\n <i class=\"ri-close-line text-2xl\"></i>\n <span class=\"sr-only\">Close</span>\n </button>\n </div>\n <p *ngIf=\"ref.config.description\" [id]=\"descId\" class=\"text-sm text-muted-foreground\">\n {{ ref.config.description }}\n </p>\n </div>\n\n <!-- Content -->\n <div class=\"grow overflow-y-auto\">\n <ng-container [ngSwitch]=\"contentType\">\n <div *ngSwitchCase=\"'string'\">{{ content }}</div>\n\n <ng-container *ngSwitchCase=\"'template'\">\n <ng-container *ngTemplateOutlet=\"asTemplate; context: ref.config.context\"></ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'component'\">\n <ng-container *ngComponentOutlet=\"asComponent\"></ng-container>\n </ng-container>\n </ng-container>\n </div>\n </div>\n `,\n styles: [`\n :host {\n display: contents;\n }\n `]\n})\nexport class SheetWrapperComponent implements OnInit {\n contentType: 'template' | 'string' | 'component' = 'string';\n content: any;\n isOpen = false;\n\n /** Stable, per-instance ids used to wire dialog ARIA relationships. */\n private readonly _uid = Math.random().toString(36).substr(2, 9);\n readonly titleId = `tolle-sheet-title-${this._uid}`;\n readonly descId = `tolle-sheet-description-${this._uid}`;\n\n public ref = inject(SheetRef);\n\n ngOnInit() {\n this.content = this.ref.config.content;\n this.determineContentType();\n\n // Trigger entry animation\n setTimeout(() => {\n this.isOpen = true;\n }, 0);\n\n // Listen for close requests\n this.ref.afterClosed$.subscribe(() => {\n this.isOpen = false;\n });\n }\n\n private determineContentType() {\n if (typeof this.content === 'string') this.contentType = 'string';\n else if (this.content instanceof TemplateRef) this.contentType = 'template';\n else this.contentType = 'component';\n }\n\n get asTemplate() { return this.content as TemplateRef<any>; }\n get asComponent() { return this.content as Type<any>; }\n\n close() {\n this.ref.close();\n }\n\n get computedClass() {\n const side = this.ref.config.side || 'right';\n const sideClasses = {\n top: \"inset-x-0 top-0 border-b slide-in-from-top data-[state=closed]:slide-out-to-top\",\n bottom: \"inset-x-0 bottom-0 border-t slide-in-from-bottom data-[state=closed]:slide-out-to-bottom\",\n left: \"inset-y-0 left-0 h-full w-3/4 border-r slide-in-from-left data-[state=closed]:slide-out-to-left sm:max-w-sm\",\n right: \"inset-y-0 right-0 h-full w-3/4 border-l slide-in-from-right data-[state=closed]:slide-out-to-right sm:max-w-sm\",\n };\n\n const roundingClasses = {\n top: \"rounded-b-2xl\",\n bottom: \"rounded-t-2xl\",\n left: \"rounded-r-2xl\",\n right: \"rounded-l-2xl\",\n };\n\n return cn(\n \"fixed z-50 flex flex-col gap-4 bg-background p-6 shadow-lg transition ease-in-out duration-300\",\n sideClasses[side],\n this.ref.config.rounded && roundingClasses[side],\n this.ref.config.class\n );\n }\n}\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "sheet.types.ts",
|
|
22
|
+
"target": "ui/sheet.types.ts",
|
|
23
|
+
"content": "import { TemplateRef, Type } from \"@angular/core\";\n\nexport type SheetSide = 'top' | 'bottom' | 'left' | 'right';\n\nexport interface SheetConfig<T = any> {\n /** The content to display (String, Component, or Template) */\n content: string | Type<any> | TemplateRef<any>;\n\n /** Optional title for the standard header */\n title?: string;\n\n /** Optional description for the standard header */\n description?: string;\n\n /** The edge of the screen where the component will appear. Defaults to 'right' */\n side?: SheetSide;\n\n /** Whether the sheet has a backdrop. Defaults to true */\n hasBackdrop?: boolean;\n\n /** If true, clicking the backdrop closes the sheet. Defaults to true */\n backdropClose?: boolean;\n\n /** Whether to show the close button. Defaults to true */\n showCloseButton?: boolean;\n\n /** Whether the sheet content has rounded corners on its inner side. Defaults to false */\n rounded?: boolean;\n\n /** Data to pass to a Component content */\n data?: { [key: string]: any };\n\n /** Context to pass to a TemplateRef content */\n context?: T;\n\n /** Additional CSS classes for the sheet content */\n class?: string;\n}\n"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "utils/cn.ts",
|
|
27
|
+
"target": "ui/utils/cn.ts",
|
|
28
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
29
|
+
}
|
|
30
|
+
]
|
|
31
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sheet",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Sheet",
|
|
5
|
+
"description": "Sheet component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "sheet.component.ts",
|
|
12
|
+
"target": "ui/sheet.component.ts",
|
|
13
|
+
"content": "import { Component, Input, Output, EventEmitter, inject, TemplateRef, ViewChild, ViewContainerRef, OnDestroy, ContentChild, forwardRef, ElementRef, AfterContentInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Overlay, OverlayRef, OverlayConfig, ScrollStrategyOptions } from '@angular/cdk/overlay';\nimport { TemplatePortal } from '@angular/cdk/portal';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-sheet',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`\n})\nexport class SheetComponent {\n @Input() isOpen = false;\n @Input() hasBackdrop = true;\n @Output() isOpenChange = new EventEmitter<boolean>();\n\n private overlay = inject(Overlay);\n private viewContainerRef = inject(ViewContainerRef);\n private scrollStrategy = inject(ScrollStrategyOptions);\n\n private overlayRef?: OverlayRef;\n /** Element focused before the sheet opened, restored on close. */\n private previouslyFocused?: HTMLElement | null;\n\n @ContentChild(forwardRef(() => SheetContentComponent)) contentComponent?: any;\n\n open() {\n if (this.isOpen) return;\n this.isOpen = true;\n this.isOpenChange.emit(true);\n this.show();\n }\n\n close() {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.isOpenChange.emit(false);\n this.hide();\n }\n\n private show() {\n if (this.overlayRef || !this.contentComponent?.contentTemplate) return;\n\n // Remember the trigger so focus can be restored when the sheet closes.\n this.previouslyFocused = document.activeElement as HTMLElement | null;\n\n const overlayConfig = new OverlayConfig({\n hasBackdrop: this.hasBackdrop,\n backdropClass: 'tolle-modal-backdrop',\n scrollStrategy: this.scrollStrategy.block(),\n positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically() // This centers key, but sheet is fixed via classes\n });\n\n this.overlayRef = this.overlay.create(overlayConfig);\n\n const portal = new TemplatePortal(this.contentComponent.contentTemplate, this.viewContainerRef);\n this.overlayRef.attach(portal);\n\n if (this.hasBackdrop) {\n this.overlayRef.backdropClick().subscribe(() => this.close());\n }\n\n // Escape-to-close.\n this.overlayRef.keydownEvents().subscribe((event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n event.preventDefault();\n this.close();\n }\n });\n }\n\n private hide() {\n if (this.overlayRef) {\n const toRestore = this.previouslyFocused;\n setTimeout(() => {\n if (this.overlayRef) {\n this.overlayRef.detach();\n this.overlayRef.dispose();\n this.overlayRef = undefined;\n }\n // Restore focus to the trigger after the exit animation.\n toRestore?.focus?.();\n }, 300);\n }\n }\n\n ngOnDestroy() {\n // Immediate cleanup on destroy\n if (this.overlayRef) {\n this.overlayRef.dispose();\n }\n }\n}\n\n@Component({\n selector: 'tolle-sheet-trigger',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n '(click)': 'onClick()',\n 'class': 'inline-block'\n }\n})\nexport class SheetTriggerComponent {\n private sheet = inject(SheetComponent);\n\n onClick() {\n this.sheet.open();\n }\n}\n\n@Component({\n selector: 'tolle-sheet-content',\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `\n <ng-template #sheetContent>\n <div\n [class]=\"computedClass\"\n [attr.data-state]=\"sheet.isOpen ? 'open' : 'closed'\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"titleCmp?.id || null\"\n [attr.aria-describedby]=\"descriptionCmp?.id || null\"\n cdkTrapFocus cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n <button\n (click)=\"close()\"\n class=\"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none\"\n >\n <i class=\"ri-close-line text-xl\"></i>\n <span class=\"sr-only\">Close</span>\n </button>\n <ng-content></ng-content>\n </div>\n </ng-template>\n `\n})\nexport class SheetContentComponent {\n @Input() side: 'top' | 'bottom' | 'left' | 'right' = 'right';\n @Input() rounded: boolean = false;\n @Input() class: string = '';\n\n @ViewChild('sheetContent', { static: true }) contentTemplate!: TemplateRef<any>;\n\n /** Projected title/description used to build the dialog's accessible name. */\n @ContentChild(forwardRef(() => SheetTitleComponent)) titleCmp?: SheetTitleComponent;\n @ContentChild(forwardRef(() => SheetDescriptionComponent)) descriptionCmp?: SheetDescriptionComponent;\n\n public sheet = inject(SheetComponent);\n\n close() {\n this.sheet.close();\n }\n\n get computedClass() {\n const sideClasses = {\n top: \"inset-x-0 top-0 border-b data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top\",\n bottom: \"inset-x-0 bottom-0 border-t data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom\",\n left: \"inset-y-0 left-0 h-full w-3/4 border-r data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm\",\n right: \"inset-y-0 right-0 h-full w-3/4 border-l data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm\",\n };\n\n const roundingClasses = {\n top: \"rounded-b-2xl\",\n bottom: \"rounded-t-2xl\",\n left: \"rounded-r-2xl\",\n right: \"rounded-l-2xl\",\n };\n\n return cn(\n \"fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=open]:duration-500 data-[state=closed]:duration-300\",\n sideClasses[this.side],\n this.rounded && roundingClasses[this.side],\n this.class\n );\n }\n}\n\n@Component({\n selector: 'tolle-sheet-header',\n standalone: true,\n template: `\n <div class=\"flex flex-col space-y-2 text-center sm:text-left\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class SheetHeaderComponent { }\n\n@Component({\n selector: 'tolle-sheet-footer',\n standalone: true,\n template: `\n <div class=\"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class SheetFooterComponent { }\n\n@Component({\n selector: 'tolle-sheet-title',\n standalone: true,\n template: `\n <h3 class=\"text-lg font-semibold text-foreground\">\n <ng-content></ng-content>\n </h3>\n `,\n host: { '[attr.id]': 'id' }\n})\nexport class SheetTitleComponent {\n /** Stable id referenced by the sheet's `aria-labelledby`. */\n readonly id = `tolle-sheet-title-${Math.random().toString(36).substr(2, 9)}`;\n}\n\n@Component({\n selector: 'tolle-sheet-description',\n standalone: true,\n template: `\n <p class=\"text-sm text-muted-foreground\">\n <ng-content></ng-content>\n </p>\n `,\n host: { '[attr.id]': 'id' }\n})\nexport class SheetDescriptionComponent {\n /** Stable id referenced by the sheet's `aria-describedby`. */\n readonly id = `tolle-sheet-description-${Math.random().toString(36).substr(2, 9)}`;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "sidebar",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Sidebar",
|
|
5
|
+
"description": "Sidebar component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "sidebar.component.ts",
|
|
12
|
+
"target": "ui/sidebar.component.ts",
|
|
13
|
+
"content": "import { Component, Input, OnChanges, SimpleChanges } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { RouterModule } from '@angular/router';\nimport { cn } from './utils/cn';\n\n// --- Interfaces ---\nexport type SidebarItem = {\n title: string;\n url?: string;\n icon?: string;\n isActive?: boolean;\n items?: SidebarItem[];\n id?: string;\n expanded?: boolean;\n /** Render as a plain anchor to an external URL / static file (opens in a new tab) instead of a router link. */\n external?: boolean;\n}\n\nexport type SidebarGroup = {\n title: string;\n items: SidebarItem[];\n id?: string;\n}\n\n@Component({\n selector: 'tolle-sidebar',\n standalone: true,\n imports: [CommonModule, RouterModule],\n template: `\n <aside [class]=\"cn(\n 'flex flex-col h-full bg-background transition-[width] duration-300 ease-in-out shrink-0 overflow-hidden',\n collapsed ? 'w-16' : 'w-64',\n class\n )\">\n <div class=\"flex h-14 shrink-0 items-center px-3 overflow-hidden whitespace-nowrap\">\n <ng-content select=\"[header]\"></ng-content>\n </div>\n\n <div class=\"flex-1 min-h-0 overflow-y-auto overflow-x-hidden py-4 px-3\">\n\n @for (group of items; track group.id || group.title || $index; let gIndex = $index) {\n <div class=\"mb-6\">\n\n <div [class]=\"cn(\n 'mb-2 px-2 text-xs font-semibold uppercase tracking-wider text-muted-foreground/70 transition-all duration-200 overflow-hidden',\n collapsed ? 'opacity-0 h-0 mb-0 px-0' : 'opacity-100 h-auto'\n )\">\n {{ group.title }}\n </div>\n\n <div class=\"flex flex-col gap-1\">\n @for (item of group.items; track item.id || item.title || $index; let i = $index) {\n\n @if (item.items && item.items.length) {\n <!-- Parent Item (Group) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"toggleParent(group, item, gIndex, i)\"\n [disabled]=\"collapsed\"\n [attr.aria-expanded]=\"isParentExpanded(group, item, gIndex, i)\"\n [class]=\"cn(\n 'group w-full relative flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground',\n isParentExpanded(group, item, gIndex, i) ? 'text-foreground bg-accent/50' : 'text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n\n @if (!collapsed) {\n <i [class]=\"cn(\n 'ri-arrow-down-s-line ml-auto transition-transform duration-300',\n isParentExpanded(group, item, gIndex, i) ? 'rotate-180' : ''\n )\"></i>\n }\n </button>\n\n <!-- Submenu Grid Transition -->\n <div [class]=\"cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isParentExpanded(group, item, gIndex, i) && !collapsed ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'\n )\">\n <div class=\"overflow-hidden\">\n <div class=\"flex flex-col gap-1 mt-1 ml-4 border-l border-border/50 pl-2\">\n\n @for (subItem of item.items; track subItem.id || subItem.title || $index; let j = $index) {\n @if (subItem.items && subItem.items.length) {\n <!-- Grandchild Item Group (Recursive-ish logic for depth 2) -->\n <div class=\"relative\">\n <button\n type=\"button\"\n (click)=\"toggleChild(group, item, subItem, gIndex, i, j); $event.stopPropagation()\"\n [attr.aria-expanded]=\"isChildExpanded(group, item, subItem, gIndex, i, j)\"\n [class]=\"cn(\n 'group w-full relative flex items-center rounded-md px-3 py-1.5 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'text-foreground bg-accent/30' : ''\n )\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border shrink-0\"></span>\n <span class=\"ml-2 truncate\">{{ subItem.title }}</span>\n <i [class]=\"cn(\n 'ri-arrow-right-s-line ml-auto transition-transform duration-300 text-xs',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'rotate-90' : ''\n )\"></i>\n </button>\n\n <div [class]=\"cn(\n 'grid transition-[grid-template-rows] duration-300 ease-in-out',\n isChildExpanded(group, item, subItem, gIndex, i, j) ? 'grid-rows-[1fr] opacity-100' : 'grid-rows-[0fr] opacity-0'\n )\">\n <div class=\"overflow-hidden\">\n <div class=\"flex flex-col gap-1 mt-1 ml-4 border-l border-border/30 pl-2\">\n @for (grandChild of subItem.items; track grandChild.title) {\n <a\n [routerLink]=\"grandChild.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 hover:no-underline rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground text-muted-foreground whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border/70 shrink-0\"></span>\n <span class=\"truncate\">{{ grandChild.title }}</span>\n </a>\n }\n </div>\n </div>\n </div>\n </div>\n }\n @else {\n <!-- Standard Sub Item -->\n <a\n [routerLink]=\"subItem.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n class=\"flex items-center gap-2 hover:no-underline rounded-md px-3 py-1.5 text-sm transition-colors hover:bg-accent hover:text-accent-foreground text-muted-foreground whitespace-nowrap focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\"\n >\n <span class=\"w-1.5 h-1.5 rounded-full bg-border shrink-0\"></span>\n <span class=\"truncate\">{{ subItem.title }}</span>\n </a>\n }\n }\n </div>\n </div>\n </div>\n </div>\n }\n @else if (item.external) {\n <!-- External / static-file Item (opens in a new tab) -->\n <a\n [href]=\"item.url\"\n target=\"_blank\"\n rel=\"noopener noreferrer\"\n [class]=\"cn(\n 'group relative hover:no-underline flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n @if (!collapsed) {\n <i aria-hidden=\"true\" class=\"ri-external-link-line ml-auto text-xs opacity-50 transition-opacity group-hover:opacity-100\"></i>\n }\n </a>\n }\n @else {\n <!-- Standard Top-Level Item -->\n <a\n [routerLink]=\"item.url\"\n [routerLinkActive]=\"activeClasses\"\n [routerLinkActiveOptions]=\"{ exact: true }\"\n [class]=\"cn(\n 'group relative hover:no-underline flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n 'hover:bg-accent hover:text-accent-foreground text-muted-foreground',\n collapsed ? 'justify-center' : 'justify-start'\n )\"\n >\n <i aria-hidden=\"true\" [class]=\"cn(\n item.icon || 'ri-circle-fill',\n 'h-4 w-4 text-lg shrink-0 transition-transform',\n !collapsed && 'group-hover:scale-110'\n )\"></i>\n <span [class]=\"cn(\n 'truncate transition-all duration-300',\n collapsed ? 'opacity-0 w-0 ml-0' : 'opacity-100 w-auto ml-3'\n )\">\n {{ item.title }}\n </span>\n </a>\n }\n }\n </div>\n </div>\n }\n </div>\n\n <div class=\"shrink-0 p-3 overflow-hidden whitespace-nowrap\">\n <ng-content select=\"[footer]\"></ng-content>\n </div>\n </aside>\n `,\n styles: [`\n :host { display: block; height: 100%; }\n button:disabled { cursor: not-allowed; opacity: 0.6; }\n * { transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); }\n `]\n})\nexport class SidebarComponent implements OnChanges {\n @Input() items: SidebarGroup[] = [];\n @Input() collapsed = false;\n @Input() class = '';\n\n /**\n * Styling variant for active items.\n * - default: Solid primary background, light text (Matches Badge Default)\n * - secondary: Solid secondary background\n * - ghost: Transparent background, accent text (Shadcn standard)\n * - outline: Bordered\n */\n @Input() variant: 'default' | 'secondary' | 'ghost' | 'outline' = 'default';\n\n expandedParents = new Set<string>();\n expandedChildren = new Set<string>();\n\n protected readonly cn = cn;\n\n get activeClasses(): string {\n return cn(\n 'transition-colors',\n // Default: Solid Primary (Badge-like) with lighter opacity (80% instead of 90%)\n this.variant === 'default' && 'bg-primary/80 text-primary-foreground hover:bg-primary hover:text-primary-foreground',\n\n // Secondary: Solid Secondary\n this.variant === 'secondary' && 'bg-secondary text-secondary-foreground hover:bg-secondary/80',\n\n // Ghost: Subtle background (Sidebar standard)\n this.variant === 'ghost' && 'bg-accent text-accent-foreground',\n\n // Outline: Bordered\n this.variant === 'outline' && 'border border-border bg-background text-foreground hover:bg-accent hover:text-accent-foreground'\n );\n }\n\n ngOnChanges(changes: SimpleChanges) {\n if (changes['collapsed'] && this.collapsed) {\n this.collapseAll();\n return;\n }\n if (changes['items']) {\n this.initializeState();\n }\n }\n\n // --- STATE INIT ---\n private initializeState() {\n this.collapseAll();\n if (!this.items) return;\n\n this.items.forEach((group, gIndex) => {\n group.items.forEach((item, iIndex) => {\n const parentId = this.getParentId(group, item, gIndex, iIndex);\n\n if (item.expanded) {\n this.expandedParents.add(parentId);\n }\n\n if (item.items) {\n item.items.forEach((subItem, sIndex) => {\n if (subItem.expanded) {\n const childId = this.getChildId(group, item, subItem, gIndex, iIndex, sIndex);\n this.expandedChildren.add(childId);\n this.expandedParents.add(parentId);\n }\n });\n }\n });\n });\n }\n\n // --- ID GENERATORS ---\n getGroupId(group: SidebarGroup, index: number): string {\n return group.id || group.title || `g-${index}`;\n }\n\n getParentId(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number): string {\n const groupId = this.getGroupId(group, gIndex);\n const itemId = item.id || item.title || `p-${iIndex}`;\n return `${groupId}__${itemId}`;\n }\n\n getChildId(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number): string {\n const parentId = this.getParentId(group, parent, gIndex, iIndex);\n const subItemId = subItem.id || subItem.title || `c-${sIndex}`;\n return `${parentId}__${subItemId}`;\n }\n\n // --- ACTIONS ---\n toggleParent(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number) {\n if (this.collapsed) return;\n const id = this.getParentId(group, item, gIndex, iIndex);\n\n const newSet = new Set(this.expandedParents);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedParents = newSet;\n }\n\n toggleChild(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number) {\n const id = this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex);\n\n const newSet = new Set(this.expandedChildren);\n if (newSet.has(id)) {\n newSet.delete(id);\n } else {\n newSet.add(id);\n }\n this.expandedChildren = newSet;\n }\n\n private collapseAll() {\n this.expandedParents = new Set();\n this.expandedChildren = new Set();\n }\n\n // --- TEMPLATE HELPERS ---\n isParentExpanded(group: SidebarGroup, item: SidebarItem, gIndex: number, iIndex: number): boolean {\n return this.expandedParents.has(this.getParentId(group, item, gIndex, iIndex));\n }\n\n isChildExpanded(group: SidebarGroup, parent: SidebarItem, subItem: SidebarItem, gIndex: number, iIndex: number, sIndex: number): boolean {\n return this.expandedChildren.has(this.getChildId(group, parent, subItem, gIndex, iIndex, sIndex));\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "skeleton",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Skeleton",
|
|
5
|
+
"description": "Skeleton component with variant options.",
|
|
6
|
+
"category": "feedback",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "skeleton.component.ts",
|
|
14
|
+
"target": "ui/skeleton.component.ts",
|
|
15
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst skeletonVariants = cva(\n // The background matches the Google Dark Mode \"Muted\" color\n 'animate-pulse bg-muted dark:bg-secondary rounded-md',\n {\n variants: {\n variant: {\n rect: '',\n circle: 'rounded-full',\n pill: 'rounded-full',\n },\n },\n defaultVariants: {\n variant: 'rect',\n },\n }\n);\n\nexport type SkeletonProps = VariantProps<typeof skeletonVariants>;\n\n@Component({\n selector: 'tolle-skeleton',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"computedClass\"></div>\n `,\n})\nexport class SkeletonComponent {\n /** Shape of the skeleton placeholder. @default 'rect' */\n @Input() variant: SkeletonProps['variant'] = 'rect';\n /** Extra Tailwind classes merged onto the skeleton via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(skeletonVariants({ variant: this.variant }), this.class);\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "slider",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Slider",
|
|
5
|
+
"description": "Slider component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "slider.component.ts",
|
|
12
|
+
"target": "ui/slider.component.ts",
|
|
13
|
+
"content": "import { Component, Input, forwardRef, ElementRef, ViewChild, HostListener } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-slider',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div \n #container\n [class]=\"cn('relative flex w-full touch-none select-none items-center py-4 cursor-pointer', class, disabled ? 'opacity-50 pointer-events-none' : '')\"\n (pointerdown)=\"onPointerDown($event)\"\n >\n <!-- Track -->\n <div class=\"relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20\">\n <div\n class=\"absolute h-full bg-primary transition-all duration-75\"\n [style.left.%]=\"rangeStart\"\n [style.width.%]=\"rangeWidth\"\n ></div>\n </div>\n \n <!-- Thumbs -->\n <div\n *ngFor=\"let val of values; let i = index\"\n class=\"absolute block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 hover:border-primary cursor-grab active:cursor-grabbing z-10\"\n [style.left.%]=\"getPercentage(val)\"\n [style.transform]=\"'translateX(-50%)'\"\n [attr.tabindex]=\"disabled ? null : 0\"\n [attr.role]=\"'slider'\"\n [attr.aria-valuenow]=\"val\"\n [attr.aria-valuemin]=\"min\"\n [attr.aria-valuemax]=\"max\"\n (keydown)=\"onKeyDown($event, i)\"\n (focus)=\"activeIndex = i\"\n ></div>\n </div>\n `,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SliderComponent),\n multi: true\n }\n ],\n styles: [`\n :host { display: block; width: 100%; }\n `]\n})\nexport class SliderComponent implements ControlValueAccessor {\n @Input() min: number = 0;\n @Input() max: number = 100;\n @Input() step: number = 1;\n @Input() disabled: boolean = false;\n @Input() class: string = '';\n\n @ViewChild('container', { static: true }) container!: ElementRef<HTMLElement>;\n\n values: number[] = [0];\n activeIndex: number = -1;\n isDragging = false;\n cn = cn;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n get isRange() {\n return this.values.length > 1;\n }\n\n get rangeStart() {\n if (!this.isRange) return 0;\n const minVal = Math.min(...this.values);\n return this.getPercentage(minVal);\n }\n\n get rangeWidth() {\n if (!this.isRange) {\n return this.getPercentage(this.values[0]);\n }\n const minVal = Math.min(...this.values);\n const maxVal = Math.max(...this.values);\n return this.getPercentage(maxVal) - this.getPercentage(minVal);\n }\n\n getPercentage(value: number) {\n const p = ((value - this.min) / (this.max - this.min)) * 100;\n return Math.max(0, Math.min(100, p));\n }\n\n getValueFromPosition(clientX: number) {\n const rect = this.container.nativeElement.getBoundingClientRect();\n const percentage = (clientX - rect.left) / rect.width;\n let value = this.min + percentage * (this.max - this.min);\n\n // Snapping to step\n value = Math.round(value / this.step) * this.step;\n return Math.max(this.min, Math.min(this.max, value));\n }\n\n onPointerDown(event: PointerEvent) {\n if (this.disabled) return;\n\n this.isDragging = true;\n this.onTouched();\n this.container.nativeElement.setPointerCapture(event.pointerId);\n\n const newValue = this.getValueFromPosition(event.clientX);\n\n // Find closest thumb\n let closestIndex = 0;\n let minDistance = Math.abs(this.values[0] - newValue);\n\n for (let i = 1; i < this.values.length; i++) {\n const distance = Math.abs(this.values[i] - newValue);\n if (distance < minDistance) {\n minDistance = distance;\n closestIndex = i;\n }\n }\n\n this.activeIndex = closestIndex;\n this.updateValue(newValue, closestIndex);\n }\n\n @HostListener('pointermove', ['$event'])\n onPointerMove(event: PointerEvent) {\n if (!this.isDragging || this.activeIndex === -1) return;\n\n const newValue = this.getValueFromPosition(event.clientX);\n this.updateValue(newValue, this.activeIndex);\n }\n\n @HostListener('pointerup', ['$event'])\n onPointerUp(event: PointerEvent) {\n if (!this.isDragging) return;\n this.isDragging = false;\n this.container.nativeElement.releasePointerCapture(event.pointerId);\n }\n\n onKeyDown(event: KeyboardEvent, index: number) {\n if (this.disabled) return;\n\n let delta = 0;\n if (event.key === 'ArrowRight' || event.key === 'ArrowUp') delta = this.step;\n if (event.key === 'ArrowLeft' || event.key === 'ArrowDown') delta = -this.step;\n\n if (delta !== 0) {\n event.preventDefault();\n const newValue = Math.max(this.min, Math.min(this.max, this.values[index] + delta));\n this.updateValue(newValue, index);\n }\n }\n\n private updateValue(value: number, index: number) {\n if (this.values[index] === value) return;\n\n const newValues = [...this.values];\n newValues[index] = value;\n\n if (this.isRange) {\n // Track the value we are dragging\n const draggingValue = newValues[index];\n\n // Sort\n newValues.sort((a, b) => a - b);\n\n // Find new index of the dragging value\n this.activeIndex = newValues.indexOf(draggingValue);\n }\n\n this.values = newValues;\n this.emitValue();\n }\n\n private emitValue() {\n const valueToEmit = this.isRange ? this.values : this.values[0];\n this.onChange(valueToEmit);\n }\n\n writeValue(value: any): void {\n if (value === null || value === undefined) {\n this.values = [this.min];\n } else if (Array.isArray(value)) {\n this.values = [...value].sort((a, b) => a - b);\n } else {\n this.values = [+value];\n }\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState?(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "switch",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Switch",
|
|
5
|
+
"description": "Switch component with size and checked options.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "switch.component.ts",
|
|
14
|
+
"target": "ui/switch.component.ts",
|
|
15
|
+
"content": "import { Component, Input, forwardRef, ChangeDetectorRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst switchTrackVariants = cva(\n 'peer inline-flex shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent 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:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-4 w-7',\n sm: 'h-5 w-9',\n default: 'h-6 w-11',\n lg: 'h-7 w-[3.25rem]',\n },\n checked: {\n true: 'bg-primary',\n false: 'bg-input',\n },\n },\n defaultVariants: {\n size: 'default',\n checked: false,\n },\n }\n);\n\nconst switchThumbVariants = cva(\n 'pointer-events-none block rounded-full bg-background shadow-lg ring-0 transition-transform',\n {\n variants: {\n size: {\n xs: 'h-3 w-3',\n sm: 'h-4 w-4',\n default: 'h-5 w-5',\n lg: 'h-6 w-6',\n },\n checked: {\n true: '',\n false: 'translate-x-0',\n },\n },\n compoundVariants: [\n { size: 'xs', checked: true, class: 'translate-x-3' },\n { size: 'sm', checked: true, class: 'translate-x-4' },\n { size: 'default', checked: true, class: 'translate-x-5' },\n { size: 'lg', checked: true, class: 'translate-x-6' },\n ],\n defaultVariants: {\n size: 'default',\n checked: false,\n },\n }\n);\n\nexport type SwitchProps = VariantProps<typeof switchTrackVariants>;\n\n@Component({\n selector: 'tolle-switch',\n standalone: true,\n imports: [CommonModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SwitchComponent),\n multi: true\n }\n ],\n template: `\n <button\n type=\"button\"\n role=\"switch\"\n [attr.aria-checked]=\"checked\"\n [attr.data-state]=\"checked ? 'checked' : 'unchecked'\"\n [disabled]=\"disabled\"\n (click)=\"toggle()\"\n [class]=\"trackClass\"\n >\n <span [class]=\"thumbClass\"></span>\n </button>\n `\n})\nexport class SwitchComponent implements ControlValueAccessor {\n /** Extra Tailwind classes merged onto the switch track via `cn()` (last-wins). */\n @Input() class = '';\n /** Disables the switch and blocks toggling. @default false */\n @Input() disabled = false;\n /** Size of the switch. @default 'default' */\n @Input() size: SwitchProps['size'] = 'default';\n\n checked = false;\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get trackClass() {\n return cn(\n switchTrackVariants({ size: this.size, checked: this.checked }),\n this.class\n );\n }\n\n get thumbClass() {\n return cn(switchThumbVariants({ size: this.size, checked: this.checked }));\n }\n\n toggle() {\n if (this.disabled) return;\n this.checked = !this.checked;\n this.onChange(this.checked);\n this.onTouched();\n }\n\n // --- ControlValueAccessor ---\n writeValue(value: boolean): void {\n this.checked = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void { this.disabled = isDisabled; this.cdr.markForCheck(); }\n}\n"
|
|
16
|
+
},
|
|
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": "tabs",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Tabs",
|
|
5
|
+
"description": "Tabs component.",
|
|
6
|
+
"category": "layout",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "tabs.component.ts",
|
|
14
|
+
"target": "ui/tabs.component.ts",
|
|
15
|
+
"content": "import { Component, Input, Output, EventEmitter, Injectable, inject, ElementRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { BehaviorSubject } from 'rxjs';\n\nconst tabsListVariants = cva('', {\n variants: {\n variant: {\n default: 'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',\n underline: 'flex h-10 items-center justify-start border-b border-border w-full bg-transparent p-0 text-muted-foreground gap-8',\n },\n },\n defaultVariants: { variant: 'default' },\n});\n\nexport type TabsListProps = VariantProps<typeof tabsListVariants>;\n\nconst tabsTriggerVariants = cva(\n 'inline-flex cursor-pointer items-center justify-center whitespace-nowrap text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:cursor-default disabled:opacity-50',\n {\n variants: {\n variant: {\n default: 'rounded-sm px-3 py-1.5 data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow-sm',\n underline: 'py-2 border-b-2 border-transparent data-[state=active]:border-primary data-[state=active]:text-foreground hover:text-foreground/80 -mb-[1px]',\n },\n },\n defaultVariants: { variant: 'default' },\n }\n);\n\n@Injectable()\nclass TabsService {\n private activeTabSubject = new BehaviorSubject<string | null>(null);\n activeTab$ = this.activeTabSubject.asObservable();\n\n setActiveTab(value: string) {\n this.activeTabSubject.next(value);\n }\n\n getActiveTab() {\n return this.activeTabSubject.getValue();\n }\n}\n\n@Component({\n selector: 'tolle-tabs',\n standalone: true,\n imports: [CommonModule],\n providers: [TabsService],\n template: `<ng-content></ng-content>`,\n host: {\n '[class]': 'computedClass'\n }\n})\nexport class TabsComponent {\n @Input() defaultValue?: string;\n @Input() value?: string;\n @Output() valueChange = new EventEmitter<string>();\n @Input() class: string = '';\n\n private tabsService = inject(TabsService);\n\n ngOnInit() {\n if (this.value) {\n this.tabsService.setActiveTab(this.value);\n } else if (this.defaultValue) {\n this.tabsService.setActiveTab(this.defaultValue);\n }\n\n this.tabsService.activeTab$.subscribe(val => {\n if (val && val !== this.value) {\n this.value = val;\n this.valueChange.emit(val);\n }\n });\n }\n\n get computedClass() {\n return cn(\"w-full\", this.class);\n }\n}\n\n@Component({\n selector: 'tolle-tabs-list',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n 'role': 'tablist',\n '[class]': 'computedClass'\n }\n})\nexport class TabsListComponent {\n /** Extra Tailwind classes merged onto the tabs list via `cn()` (last-wins). */\n @Input() class: string = '';\n /** Visual style of the tab list. @default 'default' */\n @Input() variant: TabsListProps['variant'] = 'default';\n\n get computedClass() {\n return cn(tabsListVariants({ variant: this.variant }), this.class);\n }\n}\n\n@Component({\n selector: 'tolle-tabs-trigger',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n 'role': 'tab',\n '[attr.aria-selected]': 'isActive',\n '[attr.tabindex]': 'isActive ? 0 : -1',\n '[attr.id]': \"'tab-'+value\",\n '[attr.aria-controls]': \"'tabpanel-'+value\",\n '[attr.data-state]': 'isActive ? \"active\" : \"inactive\"',\n '(click)': 'onActivate()',\n '(keydown)': 'onKeydown($event)',\n '[class]': 'computedClass'\n }\n})\nexport class TabsTriggerComponent {\n @Input({ required: true }) value!: string;\n @Input() class: string = '';\n\n private tabsService = inject(TabsService);\n private tabsList = inject(TabsListComponent, { optional: true });\n private el = inject(ElementRef) as ElementRef<HTMLElement>;\n isActive = false;\n\n constructor() {\n this.tabsService.activeTab$.subscribe(active => {\n this.isActive = active === this.value;\n });\n }\n\n onActivate() {\n this.tabsService.setActiveTab(this.value);\n }\n\n onKeydown(event: KeyboardEvent) {\n const keys = ['ArrowLeft', 'ArrowRight', 'Home', 'End'];\n if (!keys.includes(event.key)) return;\n\n const host = this.el.nativeElement;\n const list = host.closest('[role=tablist]');\n if (!list) return;\n\n const triggers = Array.from(list.querySelectorAll('tolle-tabs-trigger')) as HTMLElement[];\n if (!triggers.length) return;\n\n const currentIndex = triggers.indexOf(host);\n let nextIndex = currentIndex;\n\n switch (event.key) {\n case 'ArrowLeft':\n nextIndex = currentIndex <= 0 ? triggers.length - 1 : currentIndex - 1;\n break;\n case 'ArrowRight':\n nextIndex = currentIndex >= triggers.length - 1 ? 0 : currentIndex + 1;\n break;\n case 'Home':\n nextIndex = 0;\n break;\n case 'End':\n nextIndex = triggers.length - 1;\n break;\n }\n\n const target = triggers[nextIndex];\n if (!target) return;\n\n event.preventDefault();\n const targetValue = target.id.replace(/^tab-/, '');\n if (targetValue) {\n this.tabsService.setActiveTab(targetValue);\n }\n target.focus();\n }\n\n get computedClass() {\n const variant = this.tabsList?.variant === 'underline' ? 'underline' : 'default';\n return cn(tabsTriggerVariants({ variant }), this.class);\n }\n}\n\n@Component({\n selector: 'tolle-tabs-content',\n standalone: true,\n imports: [CommonModule],\n template: `<div *ngIf=\"isActive\"><ng-content></ng-content></div>`,\n host: {\n 'role': 'tabpanel',\n '[attr.id]': \"'tabpanel-'+value\",\n '[attr.aria-labelledby]': \"'tab-'+value\",\n '[attr.tabindex]': '0',\n '[attr.data-state]': 'isActive ? \"active\" : \"inactive\"',\n '[class]': 'computedClass'\n }\n})\nexport class TabsContentComponent {\n @Input({ required: true }) value!: string;\n @Input() class: string = '';\n\n private tabsService = inject(TabsService);\n isActive = false;\n\n constructor() {\n this.tabsService.activeTab$.subscribe(active => {\n this.isActive = active === this.value;\n });\n }\n\n get computedClass() {\n return cn(\n \"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\",\n this.class\n );\n }\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tag-input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Tag Input",
|
|
5
|
+
"description": "Tag Input component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"badge"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"class-variance-authority"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "tag-input.component.ts",
|
|
16
|
+
"target": "ui/tag-input.component.ts",
|
|
17
|
+
"content": "import { Component, Input, forwardRef, ChangeDetectorRef, ElementRef, ViewChild } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { BadgeComponent } from './badge.component';\n\n@Component({\n selector: 'tolle-tag-input',\n standalone: true,\n imports: [CommonModule, FormsModule, BadgeComponent],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TagInputComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"flex flex-col gap-1.5 w-full\">\n <label\n *ngIf=\"label\"\n [for]=\"id\"\n [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n\n <div\n [class]=\"computedContainerClass\"\n (click)=\"focusInput()\"\n >\n <tolle-badge\n *ngFor=\"let tag of tags; let i = index\"\n size=\"xs\"\n variant=\"secondary\"\n [removable]=\"!disabled && !readonly\"\n (onRemove)=\"removeTag(i, $event)\">\n {{ tagPrefix }}{{ tag }}{{ tagSuffix }}\n </tolle-badge>\n\n <input\n #tagInput\n [id]=\"id\"\n type=\"text\"\n [placeholder]=\"tags.length === 0 ? placeholder : ''\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly || (maxTags != null && tags.length >= maxTags)\"\n [(ngModel)]=\"inputValue\"\n (keydown)=\"onKeydown($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"computedInputClass\"\n />\n </div>\n\n <ng-container *ngIf=\"!disabled\">\n <p\n *ngIf=\"hint && !error\"\n class=\"text-xs text-muted-foreground px-1 transition-opacity duration-200\"\n >\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n class=\"text-xs text-destructive px-1\"\n >\n {{ errorMessage }}\n </p>\n </ng-container>\n </div>\n `,\n})\nexport class TagInputComponent implements ControlValueAccessor {\n @ViewChild('tagInput') tagInputElement!: ElementRef<HTMLInputElement>;\n\n @Input() id: string = `tag-input-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label: string = '';\n @Input() hint: string = '';\n @Input() errorMessage: string = '';\n @Input() placeholder: string = 'Type and press Enter...';\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() class: string = '';\n\n // State inputs\n @Input() disabled: boolean = false;\n @Input() readonly: boolean = false;\n @Input() error: boolean = false;\n\n // Tag-specific inputs\n @Input() delimiter: string = ',';\n @Input() maxTags: number | null = null;\n @Input() allowDuplicates: boolean = false;\n @Input() tagPrefix: string = '';\n @Input() tagSuffix: string = '';\n\n tags: string[] = [];\n inputValue: string = '';\n isFocused: boolean = false;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n writeValue(values: string[]): void {\n this.tags = Array.isArray(values) ? [...values] : [];\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n onKeydown(event: KeyboardEvent): void {\n if (this.disabled || this.readonly) return;\n\n const key = event.key;\n\n // Enter creates a tag\n if (key === 'Enter') {\n event.preventDefault();\n this.commitInput();\n return;\n }\n\n // Delimiter creates a tag (strip the delimiter)\n if (key === this.delimiter) {\n event.preventDefault();\n this.commitInput();\n return;\n }\n\n // Backspace on empty input removes the last tag\n if (key === 'Backspace' && this.inputValue === '') {\n if (this.tags.length > 0) {\n this.removeTag(this.tags.length - 1);\n }\n return;\n }\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n this.commitInput();\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n removeTag(index: number, event?: MouseEvent): void {\n if (this.disabled || this.readonly) return;\n if (event) {\n event.stopPropagation();\n }\n this.tags.splice(index, 1);\n this.emitChange();\n this.focusInput();\n }\n\n focusInput(): void {\n if (!this.disabled && this.tagInputElement) {\n this.tagInputElement.nativeElement.focus();\n }\n }\n\n private commitInput(): void {\n const value = this.inputValue.trim();\n if (value === '') {\n return;\n }\n\n // Check max tags\n if (this.maxTags != null && this.tags.length >= this.maxTags) {\n this.inputValue = '';\n return;\n }\n\n // Check duplicates (case-insensitive)\n if (!this.allowDuplicates) {\n const exists = this.tags.some(\n t => t.toLowerCase() === value.toLowerCase()\n );\n if (exists) {\n this.inputValue = '';\n return;\n }\n }\n\n this.tags.push(value);\n this.inputValue = '';\n this.emitChange();\n }\n\n private emitChange(): void {\n this.onChange([...this.tags]);\n this.cdr.markForCheck();\n }\n\n protected readonly cn = cn;\n\n get computedLabelClass() {\n return cn(\n \"text-sm font-medium text-foreground leading-none transition-opacity duration-200\",\n this.disabled && \"opacity-50\"\n );\n }\n\n get computedContainerClass() {\n return cn(\n // Base styles — flex-wrap so tags flow onto multiple lines\n \"group relative flex flex-wrap items-center w-full rounded-md border transition-all duration-200\",\n \"bg-background\",\n\n // Border and shadow\n \"border-input shadow-sm\",\n\n // Sizing — min-h instead of fixed h so tags can wrap\n this.size === 'xs' && \"min-h-8 px-2 py-1 gap-1 text-xs\",\n this.size === 'sm' && \"min-h-9 px-3 py-1 gap-1.5 text-sm\",\n this.size === 'default' && \"min-h-10 px-3 py-1.5 gap-2 text-sm\",\n this.size === 'lg' && \"min-h-11 px-4 py-2 gap-3 text-base\",\n\n // Focus state\n !(this.readonly || this.disabled) && [\n \"focus-within:border-primary/80\",\n \"focus-within:ring-4\",\n \"focus-within:ring-ring/30\",\n \"focus-within:ring-offset-0\",\n \"focus-within:shadow-none\",\n ],\n\n // Error state\n this.error && [\n \"border-destructive\",\n !(this.readonly || this.disabled) && [\n \"focus-within:border-destructive/80\",\n \"focus-within:ring-destructive/30\"\n ]\n ],\n\n // Disabled state\n this.disabled && [\n \"cursor-not-allowed opacity-50\",\n \"border-opacity-50\"\n ],\n\n // Readonly state\n this.readonly && [\n \"cursor-default\",\n \"border-dashed\",\n !this.disabled && \"focus-within:ring-0 focus-within:border-opacity-100\"\n ],\n\n this.class\n );\n }\n\n get computedInputClass() {\n return cn(\n // Base styles — inline within the flex-wrap container\n \"flex-1 bg-transparent border-none p-0 min-w-[80px]\",\n \"placeholder:text-muted-foreground\",\n\n // Remove all default focus styles\n \"focus:outline-none focus:ring-0 focus:shadow-none\",\n\n // Text sizing\n this.size === 'xs' && \"text-xs\",\n this.size === 'sm' && \"text-sm\",\n this.size === 'default' && \"text-sm\",\n this.size === 'lg' && \"text-base\",\n\n // Cursor states\n this.disabled && \"cursor-not-allowed\",\n this.readonly && \"cursor-default\",\n\n // Text color\n \"text-foreground\",\n\n // Selection color\n \"selection:bg-primary/20 selection:text-foreground\"\n // NOTE: consumer `class` is applied to the container only (computedContainerClass),\n // not duplicated onto the inner <input>.\n );\n }\n}\n"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "utils/cn.ts",
|
|
21
|
+
"target": "ui/utils/cn.ts",
|
|
22
|
+
"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"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "textarea",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Textarea",
|
|
5
|
+
"description": "Textarea component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "textarea.component.ts",
|
|
12
|
+
"target": "ui/textarea.component.ts",
|
|
13
|
+
"content": "import { Component, Input, forwardRef, ViewChild, ElementRef, AfterViewInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-textarea',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => TextareaComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"flex flex-col gap-1.5 w-full\">\n <label\n *ngIf=\"label\"\n [for]=\"id\"\n [class]=\"computedLabelClass\"\n >\n {{ label }}\n </label>\n\n <div class=\"relative\">\n <textarea\n #textareaElement\n [id]=\"id\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [rows]=\"rows\"\n [(ngModel)]=\"value\"\n (input)=\"handleInput($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"textareaClasses\"\n [style.resize]=\"(autoGrow || readonly || disabled) ? 'none' : 'vertical'\"\n [style.overflow]=\"autoGrow ? 'hidden' : 'auto'\"\n [attr.aria-invalid]=\"error || null\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : (hint ? id + '-hint' : null)\"\n [attr.maxlength]=\"maxLength\"\n ></textarea>\n </div>\n\n <div *ngIf=\"(showCharacterCount || hint || errorMessage) && !disabled\" class=\"flex justify-between items-center px-1\">\n <p\n *ngIf=\"hint && !error\"\n [id]=\"id + '-hint'\"\n class=\"text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\"\n >\n {{ hint }}\n </p>\n <p\n *ngIf=\"error && errorMessage\"\n [id]=\"id + '-error'\"\n class=\"text-xs text-destructive\"\n >\n {{ errorMessage }}\n </p>\n <p\n *ngIf=\"showCharacterCount\"\n class=\"text-[10px] uppercase tracking-wider text-muted-foreground ml-auto font-medium transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideCharacterCountOnFocus\"\n >\n {{ value.length || 0 }}{{ maxLength ? ' / ' + maxLength : '' }}\n </p>\n </div>\n </div>\n `\n})\nexport class TextareaComponent implements ControlValueAccessor, AfterViewInit {\n @ViewChild('textareaElement') textareaElement!: ElementRef<HTMLTextAreaElement>;\n\n @Input() id = `textarea-${Math.random().toString(36).substring(2, 9)}`;\n @Input() label = '';\n @Input() placeholder = '';\n @Input() hint = '';\n @Input() errorMessage = '';\n @Input() rows = 3;\n @Input() maxLength?: number;\n @Input() showCharacterCount = false;\n @Input() autoGrow = false;\n @Input() error = false;\n @Input() className = '';\n\n // Focus behavior\n @Input() hideHintOnFocus = true;\n @Input() hideCharacterCountOnFocus = false;\n\n // New States\n @Input() disabled = false;\n @Input() readonly = false;\n\n value: string = '';\n isFocused: boolean = false;\n onChange: any = () => {};\n onTouched: any = () => {};\n\n ngAfterViewInit() {\n if (this.autoGrow) this.resize();\n }\n\n get computedLabelClass() {\n return cn(\n \"text-sm font-medium text-foreground leading-none transition-opacity duration-200\",\n this.disabled && \"opacity-50\"\n );\n }\n\n get textareaClasses() {\n return cn(\n // Base styles\n 'flex w-full rounded-md border bg-background px-3 py-2 text-sm',\n 'ring-offset-background transition-all duration-200',\n 'placeholder:text-muted-foreground',\n\n // Border and shadow\n 'border-input shadow-sm',\n\n // Minimum height\n 'min-h-[80px]',\n\n // Focus state - SIMPLE LIKE ZARDUI\n !(this.readonly || this.disabled) && [\n 'focus:outline-none',\n 'focus:ring-4',\n 'focus:ring-ring/30',\n 'focus:ring-offset-0',\n 'focus:shadow-none',\n // Border darkens on focus automatically\n 'focus:border-primary/80'\n ],\n\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && [\n 'focus:border-destructive/80',\n 'focus:ring-destructive/30'\n ]\n ],\n\n // Disabled state\n this.disabled && [\n 'cursor-not-allowed opacity-50',\n 'border-opacity-50'\n ],\n\n // Readonly state\n this.readonly && [\n 'cursor-default',\n 'border-dashed',\n !this.disabled && 'focus:ring-0 focus:border-opacity-100'\n ],\n\n // Scrollbar styling\n 'scrollbar-thin scrollbar-thumb-muted scrollbar-track-transparent',\n\n this.className\n );\n }\n\n handleInput(event: any) {\n if (this.readonly || this.disabled) return;\n const val = event.target.value;\n this.value = val;\n this.onChange(val);\n if (this.autoGrow) this.resize();\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n }\n\n private resize() {\n const textarea = this.textareaElement.nativeElement;\n textarea.style.height = 'auto';\n textarea.style.height = `${textarea.scrollHeight}px`;\n }\n\n writeValue(value: any): void {\n this.value = value ?? '';\n if (this.autoGrow) setTimeout(() => this.resize());\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "toaster",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Toaster",
|
|
5
|
+
"description": "Toaster component.",
|
|
6
|
+
"category": "feedback",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "toast.service.ts",
|
|
12
|
+
"target": "ui/toast.service.ts",
|
|
13
|
+
"content": "// projects/tolle/src/lib/toast.service.ts\nimport { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\nexport interface Toast {\n id: number;\n title?: string;\n description: string;\n variant?: 'default' | 'destructive' | 'success';\n duration?: number; // Custom duration in ms\n remainingTime: number;\n isPaused: boolean;\n}\n\nexport type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center';\n@Injectable({ providedIn: 'root' })\nexport class ToastService {\n private toasts: Toast[] = [];\n private toastSubject = new Subject<Toast[]>();\n toasts$ = this.toastSubject.asObservable();\n\n constructor() {\n setInterval(() => this.tick(), 100);\n }\n show(toast: Omit<Toast, 'id' | 'remainingTime' | 'isPaused'>) {\n const duration = toast.duration || 3000;\n const newToast: Toast = {\n ...toast,\n id: Date.now(),\n duration,\n remainingTime: duration,\n isPaused: false\n };\n this.toasts.push(newToast);\n this.notify();\n }\n\n private tick() {\n let changed = false;\n this.toasts.forEach(t => {\n if (!t.isPaused) {\n t.remainingTime -= 100;\n changed = true;\n }\n });\n\n const initialCount = this.toasts.length;\n this.toasts = this.toasts.filter(t => t.remainingTime > 0);\n\n if (changed || this.toasts.length !== initialCount) {\n this.notify();\n }\n }\n\n setPaused(id: number, paused: boolean) {\n const toast = this.toasts.find(t => t.id === id);\n if (toast) {\n toast.isPaused = paused;\n this.notify();\n }\n }\n\n remove(id: number) {\n this.toasts = this.toasts.filter(t => t.id !== id);\n this.notify();\n }\n\n private notify() {\n this.toastSubject.next([...this.toasts]);\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "toaster.component.ts",
|
|
17
|
+
"target": "ui/toaster.component.ts",
|
|
18
|
+
"content": "// projects/tolle/src/lib/toast-container.component.ts\nimport { Component, inject, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ToastService, Toast, ToastPosition } from './toast.service';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-toaster',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"cn('fixed z-[100] flex flex-col gap-2 w-full max-w-[380px] p-4', positionClasses)\">\n <div\n *ngFor=\"let toast of toasts$ | async\"\n role=\"status\"\n [attr.aria-live]=\"toast.variant === 'destructive' ? 'assertive' : 'polite'\"\n aria-atomic=\"true\"\n (mouseenter)=\"toastService.setPaused(toast.id, true)\"\n (mouseleave)=\"toastService.setPaused(toast.id, false)\"\n [class]=\"cn(\n 'relative overflow-hidden p-4 rounded-md border shadow-lg flex items-start justify-between gap-4 transition-all duration-300',\n getVariantClasses(toast.variant)\n )\"\n >\n <div class=\"flex gap-3\">\n <i *ngIf=\"toast.variant && icons[toast.variant]\" [class]=\"cn('text-lg', icons[toast.variant])\"></i>\n \n <div class=\"grid gap-1\">\n <div *ngIf=\"toast.title\" class=\"text-sm font-semibold leading-none\">{{ toast.title }}</div>\n <div class=\"text-xs opacity-90 leading-relaxed\">{{ toast.description }}</div>\n </div>\n </div>\n\n <button type=\"button\" aria-label=\"Close\" (click)=\"toastService.remove(toast.id)\" class=\"opacity-50 hover:opacity-100 transition-opacity\">\n <span class=\"sr-only\">Close</span>\n <i class=\"ri-close-line text-lg\"></i>\n </button>\n\n <div\n aria-hidden=\"true\"\n class=\"absolute bottom-0 left-0 h-1 transition-all duration-100 ease-linear\"\n [style.width.%]=\"(toast.remainingTime / (toast.duration || 3000)) * 100\"\n [class]=\"getProgressClasses(toast.variant)\"\n ></div>\n </div>\n </div>\n `\n})\nexport class ToastContainerComponent {\n toastService = inject(ToastService);\n @Input() position: ToastPosition = 'bottom-right';\n toasts$ = this.toastService.toasts$;\n\n icons = {\n destructive: 'ri-error-warning-line text-destructive',\n success: 'ri-checkbox-circle-line text-success',\n default: 'ri-information-line text-primary'\n };\n\n getVariantClasses(variant: string = 'default') {\n switch (variant) {\n case 'destructive':\n return 'border-destructive/50 bg-destructive/10 text-destructive';\n case 'success':\n return 'border-success/50 bg-success/10 text-success';\n default:\n return 'bg-background text-foreground border-border';\n }\n }\n\n getProgressClasses(variant: string = 'default') {\n switch (variant) {\n case 'destructive':\n return 'bg-destructive';\n case 'success':\n return 'bg-success';\n default:\n return 'bg-primary';\n }\n }\n\n get positionClasses() {\n const pos = {\n 'top-right': 'top-0 right-0 flex-col-reverse',\n 'top-left': 'top-0 left-0 flex-col-reverse',\n 'bottom-right': 'bottom-0 right-0',\n 'bottom-left': 'bottom-0 left-0',\n 'top-center': 'top-0 left-1/2 -translate-x-1/2 flex-col-reverse',\n 'bottom-center': 'bottom-0 left-1/2 -translate-x-1/2'\n };\n return pos[this.position];\n }\n protected cn = cn;\n}\n"
|
|
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,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "toggle-group",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Toggle Group",
|
|
5
|
+
"description": "Toggle Group component.",
|
|
6
|
+
"category": "actions",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"toggle"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"class-variance-authority"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "toggle-group.component.ts",
|
|
16
|
+
"target": "ui/toggle-group.component.ts",
|
|
17
|
+
"content": "import { Component, Input, Output, EventEmitter, forwardRef, ContentChildren, QueryList, AfterContentInit, AfterViewInit, inject, ChangeDetectorRef, ElementRef, HostBinding } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { ToggleComponent } from './toggle.component';\n\n@Component({\n selector: 'tolle-toggle-group',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ToggleGroupComponent),\n multi: true\n }\n ],\n host: {\n '[class]': 'computedClass',\n 'role': 'group',\n '(keydown)': 'onKeydown($event)',\n '(focusin)': 'onFocusIn($event)'\n },\n styles: [`\n :host {\n display: inline-flex;\n }\n\n /* 1. First Item: Remove right rounding */\n :host ::ng-deep tolle-toggle-group-item:first-child tolle-toggle button {\n @apply rounded-r-none;\n }\n\n /* 2. Middle Items: Remove all rounding and left border */\n :host ::ng-deep tolle-toggle-group-item:not(:first-child):not(:last-child) tolle-toggle button {\n @apply rounded-none border-l-0;\n }\n\n /* 3. Last Item: Remove left rounding and left border */\n :host ::ng-deep tolle-toggle-group-item:last-child:not(:first-child) tolle-toggle button {\n @apply rounded-l-none border-l-0;\n }\n\n /* 4. Hover/Focus State: Higher z-index to show borders/rings correctly */\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button:hover,\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button:focus,\n :host ::ng-deep tolle-toggle-group-item tolle-toggle button[data-state=on] {\n @apply z-10 relative;\n }\n `]\n})\nexport class ToggleGroupComponent implements AfterContentInit, AfterViewInit, ControlValueAccessor {\n @Input() type: 'single' | 'multiple' = 'single';\n @Input() variant: 'default' | 'outline' = 'default';\n @Input() size: 'default' | 'sm' | 'lg' = 'default';\n @Input() class: string = '';\n @Input() disabled: boolean = false;\n\n @Output() valueChange = new EventEmitter<any>();\n\n @ContentChildren(forwardRef(() => ToggleGroupItemComponent), { descendants: true }) items!: QueryList<ToggleGroupItemComponent>;\n\n private _value: any;\n private cdr = inject(ChangeDetectorRef);\n private host = inject(ElementRef<HTMLElement>);\n\n /** Index (into the full button list) of the current roving tab stop. */\n private activeIndex = 0;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n get value() { return this._value; }\n set value(v: any) {\n if (v !== this._value) {\n this._value = v;\n this.updateItemsState();\n this.onChange(v);\n this.valueChange.emit(v);\n }\n }\n\n ngAfterContentInit() {\n this.updateItemsState();\n this.items.changes.subscribe(() => {\n this.updateItemsState();\n this.cdr.markForCheck();\n // Re-apply roving tabindex once the new toggle views have rendered.\n Promise.resolve().then(() => this.applyRovingTabindex());\n });\n }\n\n ngAfterViewInit() {\n this.applyRovingTabindex();\n }\n\n get computedClass() {\n return cn(\"inline-flex items-center justify-center -space-x-px rounded-md\", this.class);\n }\n\n /** All toggle button elements in DOM order. */\n private get toggleButtons(): HTMLButtonElement[] {\n return Array.from(\n this.host.nativeElement.querySelectorAll('tolle-toggle button')\n ) as HTMLButtonElement[];\n }\n\n /**\n * Roving tabindex: exactly one toggle button is tab-reachable at a time.\n * Defaults to the current active button, falling back to the first enabled.\n */\n private applyRovingTabindex() {\n const btns = this.toggleButtons;\n if (!btns.length) return;\n\n let active = this.activeIndex;\n if (active < 0 || active >= btns.length || btns[active].disabled) {\n active = btns.findIndex(b => !b.disabled);\n if (active < 0) active = 0;\n }\n this.activeIndex = active;\n btns.forEach((b, i) => { b.tabIndex = i === active ? 0 : -1; });\n }\n\n /**\n * Toolbar keyboard nav: Arrow Left/Right (and Home/End) move focus between\n * toggle buttons without activating them (toggles activate on Enter/Space/click).\n */\n onKeydown(event: KeyboardEvent) {\n const key = event.key;\n if (key !== 'ArrowRight' && key !== 'ArrowLeft' && key !== 'Home' && key !== 'End') return;\n\n const btns = this.toggleButtons;\n const enabled = btns.map((b, i) => ({ b, i })).filter(x => !x.b.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const active = (typeof document !== 'undefined' ? document.activeElement : null) as HTMLButtonElement | null;\n const focusedIndex = active ? btns.indexOf(active) : -1;\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n\n let targetPos: number;\n if (key === 'Home') {\n targetPos = 0;\n } else if (key === 'End') {\n targetPos = enabled.length - 1;\n } else if (key === 'ArrowRight') {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n // Focusing the button triggers focusin, which updates the roving tab stop.\n enabled[targetPos].b.focus();\n }\n\n /** Keep the roving tab stop in sync when a button gains focus (tab/click). */\n onFocusIn(event: FocusEvent) {\n const btns = this.toggleButtons;\n const idx = btns.indexOf(event.target as HTMLButtonElement);\n if (idx < 0) return;\n this.activeIndex = idx;\n btns.forEach((b, i) => { b.tabIndex = i === idx ? 0 : -1; });\n }\n\n writeValue(value: any): void {\n this._value = value;\n this.updateItemsState();\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.updateItemsState();\n }\n\n toggleItem(itemValue: string) {\n if (this.disabled) return;\n\n if (this.type === 'single') {\n this.value = this.value === itemValue ? null : itemValue;\n } else {\n const current = Array.isArray(this.value) ? [...this.value] : [];\n const index = current.indexOf(itemValue);\n if (index > -1) {\n current.splice(index, 1);\n } else {\n current.push(itemValue);\n }\n this.value = current;\n }\n }\n\n private updateItemsState() {\n if (!this.items) return;\n this.items.forEach(item => {\n if (this.type === 'single') {\n item.pressed = this.value === item.value;\n } else {\n item.pressed = Array.isArray(this.value) && this.value.includes(item.value);\n }\n item.variant = this.variant;\n item.size = this.size;\n item.disabled = this.disabled || item.disabled;\n item.cdr.markForCheck();\n });\n }\n}\n\n@Component({\n selector: 'tolle-toggle-group-item',\n standalone: true,\n imports: [CommonModule, ToggleComponent],\n template: `\n <tolle-toggle\n [pressed]=\"pressed\"\n [variant]=\"variant\"\n [size]=\"size\"\n [disabled]=\"disabled\"\n (pressedChange)=\"onToggle()\"\n [class]=\"class\"\n >\n <ng-content></ng-content>\n </tolle-toggle>\n `\n})\nexport class ToggleGroupItemComponent {\n @Input({ required: true }) value!: string;\n @Input() class: string = '';\n @Input() disabled: boolean = false;\n\n pressed = false;\n variant: 'default' | 'outline' = 'default';\n size: 'default' | 'sm' | 'lg' = 'default';\n\n private group = inject(ToggleGroupComponent);\n public cdr = inject(ChangeDetectorRef);\n\n onToggle() {\n this.group.toggleItem(this.value);\n }\n}\n"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"path": "utils/cn.ts",
|
|
21
|
+
"target": "ui/utils/cn.ts",
|
|
22
|
+
"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"
|
|
23
|
+
}
|
|
24
|
+
]
|
|
25
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "toggle",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Toggle",
|
|
5
|
+
"description": "Toggle component with variant and size options.",
|
|
6
|
+
"category": "actions",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "toggle.component.ts",
|
|
14
|
+
"target": "ui/toggle.component.ts",
|
|
15
|
+
"content": "import { Component, Input, Output, EventEmitter, forwardRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst toggleVariants = cva(\n \"inline-flex items-center justify-center rounded-md text-sm font-medium ring-offset-background transition-colors hover:bg-muted hover:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 data-[state=on]:bg-accent data-[state=on]:text-accent-foreground\",\n {\n variants: {\n variant: {\n default: \"bg-transparent\",\n outline: \"border border-input bg-transparent hover:bg-accent hover:text-accent-foreground\",\n },\n size: {\n default: \"h-10 px-3\",\n sm: \"h-9 px-2.5\",\n lg: \"h-11 px-5\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n size: \"default\",\n },\n }\n);\n\nexport type ToggleProps = VariantProps<typeof toggleVariants>;\n\n@Component({\n selector: 'tolle-toggle',\n standalone: true,\n imports: [CommonModule],\n template: `\n <button\n type=\"button\"\n [class]=\"computedClass\"\n [disabled]=\"disabled\"\n [attr.aria-pressed]=\"pressed\"\n [attr.data-state]=\"pressed ? 'on' : 'off'\"\n (click)=\"toggle()\"\n >\n <ng-content></ng-content>\n </button>\n `,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ToggleComponent),\n multi: true\n }\n ]\n})\nexport class ToggleComponent implements ControlValueAccessor {\n @Input() variant: ToggleProps['variant'] = 'default';\n @Input() size: ToggleProps['size'] = 'default';\n @Input() disabled: boolean = false;\n @Input() class: string = '';\n\n @Input() set pressed(val: boolean) {\n this._pressed = val;\n this.onChange(val);\n }\n get pressed() { return this._pressed; }\n private _pressed = false;\n\n @Output() pressedChange = new EventEmitter<boolean>();\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n get computedClass() {\n return cn(\n toggleVariants({\n variant: this.variant,\n size: this.size,\n }),\n this.class\n );\n }\n\n toggle() {\n if (this.disabled) return;\n this.pressed = !this.pressed;\n this.pressedChange.emit(this.pressed);\n this.onTouched();\n }\n\n writeValue(value: any): void {\n this._pressed = !!value;\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n\n setDisabledState?(isDisabled: boolean): void {\n this.disabled = isDisabled;\n }\n}\n"
|
|
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": "tolle-cell",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Tolle Cell",
|
|
5
|
+
"description": "Tolle Cell component.",
|
|
6
|
+
"category": "data",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "tolle-cell.directive.ts",
|
|
12
|
+
"target": "ui/tolle-cell.directive.ts",
|
|
13
|
+
"content": "import { Directive, Input, TemplateRef } from '@angular/core';\n\n@Directive({\n selector: '[tolleCell]',\n standalone: true\n})\nexport class TolleCellDirective {\n @Input('tolleCell') name!: string; // The column key this template belongs to\n constructor(public template: TemplateRef<any>) {}\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "tooltip",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Tooltip",
|
|
5
|
+
"description": "Tooltip component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"@floating-ui/dom"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "tooltip.directive.ts",
|
|
14
|
+
"target": "ui/tooltip.directive.ts",
|
|
15
|
+
"content": "import {\n Directive, ElementRef, HostListener, Input,\n OnDestroy, ComponentRef, ViewContainerRef\n} from '@angular/core';\nimport { computePosition, flip, shift, offset, arrow, autoUpdate } from '@floating-ui/dom';\nimport { cn } from './utils/cn';\n\nlet tooltipIdCounter = 0;\n\n@Directive({\n selector: '[tolleTooltip]',\n standalone: true\n})\nexport class TooltipDirective implements OnDestroy {\n @Input('tolleTooltip') content: string = '';\n @Input() placement: 'top' | 'bottom' | 'left' | 'right' = 'top';\n\n private tooltipEl: HTMLDivElement | null = null;\n private cleanup?: () => void;\n private showTimeout?: any;\n private readonly tooltipId = `tolle-tooltip-${tooltipIdCounter++}`;\n\n constructor(private el: ElementRef) {}\n\n @HostListener('mouseenter')\n show() {\n if (!this.content) return;\n if (this.showTimeout || this.tooltipEl) return;\n\n // Delay showing the tooltip (~600ms) to avoid flicker on quick pointer passes.\n this.showTimeout = setTimeout(() => {\n this.showTimeout = undefined;\n\n // 1. Create Tooltip Element\n this.tooltipEl = document.createElement('div');\n this.tooltipEl.setAttribute('role', 'tooltip');\n this.tooltipEl.id = this.tooltipId;\n this.tooltipEl.className = cn(\n 'absolute z-[100] px-2 py-1 text-xs font-medium bg-popover text-popover-foreground border border-border rounded-md shadow-md pointer-events-none'\n );\n this.tooltipEl.innerText = this.content;\n document.body.appendChild(this.tooltipEl);\n\n // Associate the tooltip with its host for assistive technology.\n this.el.nativeElement.setAttribute('aria-describedby', this.tooltipId);\n\n // 2. Position it using Floating UI\n this.cleanup = autoUpdate(this.el.nativeElement, this.tooltipEl, () => {\n computePosition(this.el.nativeElement, this.tooltipEl!, {\n placement: this.placement,\n middleware: [offset(8), flip(), shift({ padding: 5 })],\n }).then(({ x, y }) => {\n Object.assign(this.tooltipEl!.style, {\n left: `${x}px`,\n top: `${y}px`,\n });\n });\n });\n }, 600);\n }\n\n @HostListener('focusin')\n onFocus() {\n this.show();\n }\n\n @HostListener('mouseleave')\n @HostListener('focusout')\n hide() {\n if (this.showTimeout) {\n clearTimeout(this.showTimeout);\n this.showTimeout = undefined;\n }\n if (this.tooltipEl) {\n this.tooltipEl.remove();\n this.tooltipEl = null;\n }\n if (this.cleanup) {\n this.cleanup();\n }\n this.el.nativeElement.removeAttribute('aria-describedby');\n }\n\n ngOnDestroy() {\n this.hide();\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
|
+
}
|