@tolle_/tolle-ui 18.2.23 → 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 +60 -25
- package/esm2022/lib/date-range-picker.component.mjs +64 -26
- 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 +2715 -682
- 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 +8 -1
- 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 +2 -1
- package/lib/date-range-picker.component.d.ts +5 -2
- 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/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 +60 -5
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "data-table",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Data Table",
|
|
5
|
+
"description": "Data Table component.",
|
|
6
|
+
"category": "data",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"checkbox",
|
|
9
|
+
"input",
|
|
10
|
+
"pagination",
|
|
11
|
+
"popover",
|
|
12
|
+
"popover-content",
|
|
13
|
+
"select",
|
|
14
|
+
"select-item",
|
|
15
|
+
"tolle-cell"
|
|
16
|
+
],
|
|
17
|
+
"dependencies": [
|
|
18
|
+
"@floating-ui/dom",
|
|
19
|
+
"class-variance-authority"
|
|
20
|
+
],
|
|
21
|
+
"files": [
|
|
22
|
+
{
|
|
23
|
+
"path": "data-table.component.ts",
|
|
24
|
+
"target": "ui/data-table.component.ts",
|
|
25
|
+
"content": "import {\n Component, Input, OnInit, OnChanges, SimpleChanges,\n TemplateRef, ContentChildren, QueryList\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\nimport { PaginationComponent } from './pagination.component';\nimport { TolleCellDirective } from './tolle-cell.directive';\nimport { InputComponent } from './input.component';\nimport { PopoverComponent } from './popover.component';\nimport { PopoverContentComponent } from './popover-content.component';\nimport { CheckboxComponent } from './checkbox.component';\n\nexport interface TableColumn {\n key: string;\n label: string;\n sortable?: boolean;\n class?: string;\n}\n\n@Component({\n selector: 'tolle-data-table',\n standalone: true,\n styles: [`\n :host { display: block; width: 100%; height: 100%; }\n table { width: 100%; border-collapse: collapse; display: table; }\n thead { display: table-header-group; }\n tbody { display: table-row-group; }\n tr { display: table-row; }\n th, td { display: table-cell; }\n `],\n imports: [\n CommonModule,\n FormsModule,\n PaginationComponent,\n InputComponent,\n PopoverComponent,\n PopoverContentComponent,\n CheckboxComponent,\n ],\n template: `\n <div class=\"h-full flex flex-col w-full\">\n <div class=\"flex items-center justify-between py-2 flex-none\">\n <div *ngIf=\"searchable\" class=\"w-full max-w-sm\">\n <tolle-input\n [size]=\"size === 'lg' ? 'default' : 'sm'\"\n class=\"w-full\"\n aria-label=\"Filter records\"\n placeholder=\"Filter records...\"\n [(ngModel)]=\"searchTerm\"\n (ngModelChange)=\"onSearch()\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n </div>\n\n <tolle-popover *ngIf=\"allowColumnHiding && showSettings\">\n <button trigger\n [class]=\"cn(\n 'ml-auto flex items-center gap-2 rounded-md border border-input bg-background px-3 hover:bg-accent hover:text-accent-foreground transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring',\n size === 'xs' ? 'h-7 text-xs' : 'h-9 text-sm'\n )\">\n <i class=\"ri-settings-3-line\"></i>\n View\n <i class=\"ri-arrow-down-s-line ml-1 opacity-50\"></i>\n </button>\n <tolle-popover-content class=\"w-56 p-0 bg-background border-border\">\n <div class=\"p-2 border-b border-border\">\n <p class=\"text-xs font-medium text-muted-foreground px-2\">Toggle Columns</p>\n </div>\n <div class=\"p-2 max-h-[300px] overflow-y-auto space-y-1\">\n <label *ngFor=\"let col of columns\" class=\"flex items-center gap-2 px-2 py-1.5 rounded-sm hover:bg-accent cursor-pointer text-sm transition-colors\">\n <tolle-checkbox [ngModel]=\"columnVisibility[col.key]\"\n (ngModelChange)=\"toggleColumn(col.key)\"/>\n <span>{{ col.label }}</span>\n </label>\n </div>\n </tolle-popover-content>\n </tolle-popover>\n </div>\n\n <div class=\"flex-1 min-h-0 rounded-md border border-border overflow-hidden shadow-sm relative w-full\">\n <div class=\"overflow-auto w-full h-full\" [style.max-height]=\"maxHeight\">\n\n <table class=\"w-full table-auto border-collapse\">\n <thead class=\"border-b border-border bg-background\">\n <tr>\n <th *ngIf=\"expandable\" scope=\"col\" [class]=\"cn('px-4', size === 'xs' ? 'w-[32px]' : 'w-[48px]', stickyHeader ? 'sticky top-0 z-10 bg-background' : '')\"></th>\n\n <th *ngFor=\"let col of activeColumns\"\n scope=\"col\"\n [attr.aria-sort]=\"sortKey === col.key ? (sortDir === 'asc' ? 'ascending' : (sortDir === 'desc' ? 'descending' : 'none')) : 'none'\"\n [class]=\"cn('text-left font-medium text-foreground',headerPaddingClass,fontSizeClass, col.class, stickyHeader ? 'sticky top-0 z-10 bg-background shadow-sm' : '')\">\n\n <button *ngIf=\"col.sortable; else plainHeader\"\n type=\"button\"\n (click)=\"toggleSort(col.key)\"\n class=\"flex items-center gap-1 cursor-pointer select-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring rounded-sm\">\n {{ col.label }}\n <i [class]=\"getSortIcon(col.key)\" aria-hidden=\"true\"></i>\n </button>\n\n <ng-template #plainHeader>\n {{ col.label }}\n </ng-template>\n </th>\n </tr>\n </thead>\n\n <tbody class=\"divide-y divide-border\">\n <ng-container *ngFor=\"let row of pagedData; let i = index\">\n <tr class=\"hover:bg-muted/50 transition-colors\">\n <td *ngIf=\"expandable\" class=\"px-4\">\n <button (click)=\"toggleRow(i)\"\n [attr.aria-label]=\"expandedRows.has(i) ? 'Collapse row' : 'Expand row'\"\n [attr.aria-expanded]=\"expandedRows.has(i)\"\n [class]=\"cn(\n 'rounded-md hover:bg-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n size === 'xs' ? 'h-6 w-6' : 'h-8 w-8'\n )\">\n <i [class]=\"expandedRows.has(i)\n ? 'ri-arrow-down-s-line'\n : 'ri-arrow-right-s-line'\" aria-hidden=\"true\">\n </i>\n </button>\n </td>\n\n <td *ngFor=\"let col of activeColumns\"\n [class]=\"cn('align-middle',cellPaddingClass,fontSizeClass,col.class)\">\n\n <ng-container *ngIf=\"getTemplate(col.key) as cell; else defaultCell\">\n <ng-container\n *ngTemplateOutlet=\"cell.template; context: { $implicit: row[col.key], row: row }\">\n </ng-container>\n </ng-container>\n\n <ng-template #defaultCell>\n {{ row[col.key] }}\n </ng-template>\n </td>\n </tr>\n\n <tr *ngIf=\"expandedRows.has(i)\" class=\"bg-muted/10\">\n <td [attr.colspan]=\"activeColumns.length + (expandable ? 1 : 0)\"\n class=\"p-0\">\n <div class=\"p-6 border-t border-dashed border-border\">\n <ng-container *ngIf=\"expandedTemplate; else noExpanded\">\n <ng-container\n *ngTemplateOutlet=\"expandedTemplate; context: { row: row }\">\n </ng-container>\n </ng-container>\n\n <ng-template #noExpanded>\n <p class=\"text-xs text-muted-foreground italic\">\n No details available.\n </p>\n </ng-template>\n </div>\n </td>\n </tr>\n </ng-container>\n\n <tr *ngIf=\"pagedData.length === 0\">\n <td [attr.colspan]=\"activeColumns.length + (expandable ? 1 : 0)\"\n class=\"h-24 text-center text-muted-foreground\">\n No results found.\n </td>\n </tr>\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"flex-none py-2\">\n <tolle-pagination\n *ngIf=\"paginate && filteredData.length > 0\"\n [totalRecords]=\"filteredData.length\"\n [pageSizeOptions]=\"pageSizeOptions\"\n [currentPage]=\"currentPage\"\n [currentPageSize]=\"pageSize\"\n (onPageNumberChange)=\"handlePageChange($event)\"\n (onPageSizeChange)=\"handlePageSizeChange($event)\"\n ></tolle-pagination>\n </div>\n </div>\n `\n})\nexport class DataTableComponent implements OnInit, OnChanges {\n @Input() data: any[] = [];\n @Input() columns: TableColumn[] = [];\n @Input() searchable = true;\n @Input() paginate = true;\n @Input() pageSizeOptions: number[] = []\n @Input() pageSize = 10;\n @Input() expandable = false;\n\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() stickyHeader = false;\n @Input() maxHeight?: string;\n\n // --- Column Hiding Settings ---\n @Input() allowColumnHiding = true;\n @Input() showSettings = true;\n\n // Track visibility state\n columnVisibility: Record<string, boolean> = {};\n\n // Filters columns based on visibility state\n get activeColumns() {\n return this.columns.filter(col => this.columnVisibility[col.key]);\n }\n\n expandedRows = new Set<number>();\n @ContentChildren(TolleCellDirective) cellTemplates!: QueryList<TolleCellDirective>;\n @Input() expandedTemplate?: TemplateRef<any>;\n\n filteredData: any[] = [];\n pagedData: any[] = [];\n searchTerm = '';\n currentPage = 1;\n sortKey = '';\n sortDir: 'asc' | 'desc' | null = null;\n\n // Style Getters\n get cellPaddingClass(): string {\n switch (this.size) {\n case 'xs': return 'p-1 px-4';\n case 'sm': return 'p-2 px-4';\n case 'lg': return 'p-6 px-4';\n default: return 'p-4';\n }\n }\n\n get headerPaddingClass(): string {\n switch (this.size) {\n case 'xs': return 'h-7 px-4';\n case 'sm': return 'h-9 px-4';\n case 'lg': return 'h-14 px-4';\n default: return 'h-12 px-4';\n }\n }\n\n get fontSizeClass(): string {\n switch (this.size) {\n case 'xs': return 'text-[11px]';\n case 'sm': return 'text-xs';\n case 'lg': return 'text-base';\n default: return 'text-sm';\n }\n }\n\n protected cn = cn;\n\n ngOnInit() {\n this.initializeVisibility();\n this.refreshTable();\n }\n\n ngOnChanges(changes: SimpleChanges) {\n if (changes['data'] && !changes['data'].firstChange) {\n this.currentPage = 1; // Reset to page 1 when data changes\n this.refreshTable();\n }\n if (changes['columns']) {\n this.initializeVisibility();\n }\n }\n\n private initializeVisibility() {\n this.columns.forEach(col => {\n // Only set true if undefined, preserves user selection on other data refreshes\n if (this.columnVisibility[col.key] === undefined) {\n this.columnVisibility[col.key] = true;\n }\n });\n }\n\n refreshTable() {\n this.applySearch();\n this.applySort();\n this.updatePage();\n }\n onSearch() {\n this.currentPage = 1;\n this.refreshTable();\n }\n\n // Toggle function used by the checkbox in popover\n toggleColumn(key: string) {\n this.columnVisibility[key] = !this.columnVisibility[key];\n // No explicit refresh needed as the getter 'activeColumns' will automatically re-evaluate\n // when change detection runs after the click event.\n }\n\n private applySearch() {\n if (!this.searchTerm) {\n this.filteredData =\n [...this.data];\n return;\n }\n const q = this.searchTerm.toLowerCase();\n this.filteredData = this.data.filter(row =>\n Object.values(row).some(val => String(val).toLowerCase().includes(q))\n );\n }\n\n private applySort() {\n if (!this.sortKey || !this.sortDir) return;\n this.filteredData.sort((a, b) => {\n const valA = a[this.sortKey]; const valB = b[this.sortKey];\n if (valA < valB) return this.sortDir === 'asc' ? -1 : 1;\n if (valA > valB) return this.sortDir === 'asc' ? 1 : -1;\n return 0;\n });\n }\n\n handlePageChange(newPage: number) {\n this.currentPage = newPage;\n this.updatePage();\n }\n\n handlePageSizeChange(newSize: number) {\n if (newSize !== this.pageSize) {\n this.pageSize = newSize;\n this.currentPage = 1; // Reset to page 1 when size changes\n this.updatePage();\n }\n }\n\n updatePage() {\n if (!this.paginate) {\n this.pagedData = this.filteredData;\n return;\n }\n\n const start = (this.currentPage - 1) * this.pageSize;\n const end = start + this.pageSize;\n\n // Safety check if current page is out of bounds after filtering\n if (start >= this.filteredData.length && this.currentPage > 1) {\n this.currentPage = 1;\n this.updatePage();\n return;\n }\n\n this.pagedData = this.filteredData.slice(start, end);\n }\n\n toggleSort(key: string) {\n if (this.sortKey === key) {\n this.sortDir = this.sortDir === 'asc' ? 'desc' : this.sortDir === 'desc' ? null : 'asc';\n } else {\n this.sortKey = key;\n this.sortDir = 'asc';\n }\n this.currentPage = 1; // Reset to page 1 when sorting\n this.refreshTable();\n }\n\n getSortIcon(key: string) {\n if (this.sortKey !== key || !this.sortDir) return 'ri-expand-up-down-line opacity-20'; // Changed icon for inactive state\n return this.sortDir === 'asc' ? 'ri-arrow-up-line text-foreground' : 'ri-arrow-down-line text-foreground';\n }\n\n toggleRow(index: number) {\n if (this.expandedRows.has(index)) {\n this.expandedRows.delete(index);\n }\n else {\n this.expandedRows.add(index);\n }\n }\n\n getTemplate(key: string): TolleCellDirective | undefined {\n return this.cellTemplates?.find(t => t.name === key);\n }\n}\n"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "select.service.ts",
|
|
29
|
+
"target": "ui/select.service.ts",
|
|
30
|
+
"content": "import { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\n@Injectable()\nexport class SelectService {\n // Emits the value of the clicked item\n private selectedValueSource = new Subject<any>();\n selectedValue$ = this.selectedValueSource.asObservable();\n\n // Emits the label/text of the clicked item\n private selectedLabelSource = new Subject<string>();\n selectedLabel$ = this.selectedLabelSource.asObservable();\n\n registerClick(value: any, label: string) {\n this.selectedValueSource.next(value);\n this.selectedLabelSource.next(label);\n }\n}\n"
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"path": "utils/cn.ts",
|
|
34
|
+
"target": "ui/utils/cn.ts",
|
|
35
|
+
"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"
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "date-picker",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Date Picker",
|
|
5
|
+
"description": "Date Picker component.",
|
|
6
|
+
"category": "date-time",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"calendar",
|
|
9
|
+
"masked-input"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": [
|
|
12
|
+
"@floating-ui/dom",
|
|
13
|
+
"date-fns"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "date-picker.component.ts",
|
|
18
|
+
"target": "ui/date-picker.component.ts",
|
|
19
|
+
"content": "import {\n Component, Input, forwardRef, ElementRef, ViewChild, ChangeDetectorRef, OnDestroy, HostListener\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { format, parse, isValid, startOfDay } from 'date-fns';\nimport { cn } from './utils/cn';\nimport { MaskedInputComponent } from './masked-input.component';\nimport { CalendarComponent, CalendarMode } from './calendar.component';\n\n@Component({\n selector: 'tolle-date-picker',\n standalone: true,\n imports: [CommonModule, FormsModule, MaskedInputComponent, CalendarComponent],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => DatePickerComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"relative w-full\" #triggerContainer>\n <tolle-masked-input\n #maskInput\n [mask]=\"getMask()\"\n [placeholder]=\"getPlaceholder()\"\n [disabled]=\"disabled\"\n [(ngModel)]=\"inputValue\"\n (ngModelChange)=\"onInputChange($event)\"\n [class]=\"cn(class)\">\n <div suffix class=\"flex items-center gap-1.5\">\n <button\n *ngIf=\"value && !disabled && showClear\"\n type=\"button\"\n aria-label=\"Clear date\"\n (click)=\"clear($event)\"\n class=\"ri-close-line cursor-pointer text-muted-foreground hover:text-foreground transition-colors\"\n tabindex=\"-1\"\n ></button>\n\n <button\n type=\"button\"\n #trigger\n aria-label=\"Open calendar\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"isOpen\"\n (click)=\"togglePopover($event)\"\n [disabled]=\"disabled\"\n [class]=\"cn(\n 'cursor-pointer text-muted-foreground transition-colors',\n 'ri-calendar-line'\n )\"\n tabindex=\"-1\"\n ></button>\n </div>\n </tolle-masked-input>\n\n <div\n #popover\n *ngIf=\"isOpen\"\n role=\"dialog\"\n class=\"fixed z-[50]\"\n style=\"visibility: hidden; top: 0; left: 0;\"\n >\n <tolle-calendar class=\"shadow-lg\"\n [(ngModel)]=\"value\"\n (ngModelChange)=\"onCalendarChange($event)\"\n [mode]=\"mode\"\n [disablePastDates]=\"disablePastDates\"\n [minDate]=\"minDate\"\n [maxDate]=\"maxDate\"\n [showQuickActions]=\"showQuickActions\"\n [formatMonthFn]=\"formatMonthFn\"\n [formatYearFn]=\"formatYearFn\"\n ></tolle-calendar>\n </div>\n </div>\n `\n})\nexport class DatePickerComponent implements ControlValueAccessor, OnDestroy {\n @Input() placeholder = 'MM/DD/YYYY';\n @Input() disabled = false;\n @Input() class = '';\n @Input() disablePastDates = false;\n @Input() showClear = true;\n @Input() showQuickActions = true;\n @Input() minDate?: Date;\n @Input() maxDate?: Date;\n @Input() mode: CalendarMode = 'date';\n @Input() formatMonthFn?: (date: Date) => string;\n @Input() formatYearFn?: (date: Date) => string;\n\n // Format functions for display\n @Input() displayFormat?: (date: Date, mode: CalendarMode) => string;\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n\n value: Date | null = null;\n inputValue: string = '';\n isOpen = false;\n cleanupAutoUpdate?: () => void;\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.trigger.nativeElement.contains(event.target) && !this.popover?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n getMask(): string {\n switch (this.mode) {\n case 'date': return '00/00/0000';\n case 'month': return '00/0000';\n case 'year': return '0000';\n default: return '00/00/0000';\n }\n }\n\n getPlaceholder(): string {\n switch (this.mode) {\n case 'date': return 'MM/DD/YYYY';\n case 'month': return 'MM/YYYY';\n case 'year': return 'YYYY';\n default: return 'MM/DD/YYYY';\n }\n }\n\n getFormatString(): string {\n switch (this.mode) {\n case 'date': return 'MM/dd/yyyy';\n case 'month': return 'MM/yyyy';\n case 'year': return 'yyyy';\n default: return 'MM/dd/yyyy';\n }\n }\n\n formatDate(date: Date): string {\n if (this.displayFormat) {\n return this.displayFormat(date, this.mode);\n }\n\n switch (this.mode) {\n case 'date': return format(date, 'MM/dd/yyyy');\n case 'month': return format(date, 'MM/yyyy');\n case 'year': return format(date, 'yyyy');\n default: return format(date, 'MM/dd/yyyy');\n }\n }\n\n parseDate(str: string): Date | null {\n try {\n const parsed = parse(str, this.getFormatString(), new Date());\n return isValid(parsed) ? startOfDay(parsed) : null;\n } catch {\n return null;\n }\n }\n\n onInputChange(str: string) {\n const expectedLength = this.getFormatString().replace(/[^0]/g, '').length;\n\n if (str?.length === expectedLength) {\n const parsed = this.parseDate(str);\n if (parsed) {\n this.value = parsed;\n this.onChange(this.value);\n }\n } else if (!str) {\n this.value = null;\n this.onChange(null);\n }\n }\n\n onCalendarChange(date: Date | null) {\n this.value = date;\n if (date) {\n this.inputValue = this.formatDate(date);\n } else {\n this.inputValue = '';\n }\n this.onChange(this.value);\n this.close();\n }\n\n togglePopover(event: MouseEvent) {\n event.stopPropagation();\n if (this.disabled) return;\n this.isOpen ? this.close() : this.open();\n }\n\n @HostListener('keydown.escape') onEscape() { if (this.isOpen) this.close(); }\n\n open() {\n if (this.disabled) return;\n this.isOpen = true;\n // Render the popover synchronously so its DOM node + @ViewChild('popover') exist\n // before we position it. Otherwise, with zone eventCoalescing (Angular defers change\n // detection to the next animation frame), updatePosition() can run first, find no\n // popover, and the calendar stays hidden — regardless of where the picker is used.\n this.cdr.detectChanges();\n this.trigger.nativeElement.focus();\n requestAnimationFrame(() => {\n this.updatePosition();\n document.addEventListener('mousedown', this._outsideClickHandler);\n });\n }\n\n close() {\n this.isOpen = false;\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n clear(event: MouseEvent) {\n event.stopPropagation();\n this.value = null;\n this.inputValue = '';\n this.onChange(null);\n this.cdr.markForCheck();\n }\n\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanupAutoUpdate = autoUpdate(\n this.trigger.nativeElement,\n this.popover.nativeElement,\n () => {\n computePosition(this.trigger.nativeElement, this.popover.nativeElement, {\n strategy: 'fixed',\n placement: 'bottom-start',\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 })\n ],\n }).then(({ x, y, strategy }) => {\n Object.assign(this.popover.nativeElement.style, {\n position: strategy,\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n });\n }\n );\n }\n\n ngOnDestroy() {\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n // CVA Implementation\n onChange: any = () => {};\n onTouched: any = () => {};\n\n writeValue(val: any): void {\n if (val) {\n const date = new Date(val);\n if (isValid(date)) {\n this.value = startOfDay(date);\n this.inputValue = this.formatDate(this.value);\n }\n } else {\n this.value = null;\n this.inputValue = '';\n }\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; }\n protected cn = cn;\n}\n"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"path": "utils/cn.ts",
|
|
23
|
+
"target": "ui/utils/cn.ts",
|
|
24
|
+
"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"
|
|
25
|
+
}
|
|
26
|
+
]
|
|
27
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "date-range-picker",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Date Range Picker",
|
|
5
|
+
"description": "Date Range Picker component.",
|
|
6
|
+
"category": "date-time",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"input",
|
|
9
|
+
"range-calendar"
|
|
10
|
+
],
|
|
11
|
+
"dependencies": [
|
|
12
|
+
"@floating-ui/dom",
|
|
13
|
+
"date-fns"
|
|
14
|
+
],
|
|
15
|
+
"files": [
|
|
16
|
+
{
|
|
17
|
+
"path": "date-range-picker.component.ts",
|
|
18
|
+
"target": "ui/date-range-picker.component.ts",
|
|
19
|
+
"content": "import {\n Component, Input, forwardRef, ElementRef, ViewChild, ChangeDetectorRef, OnDestroy, HostListener\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate, size } from '@floating-ui/dom';\nimport { cn } from './utils/cn';\nimport { RangeCalendarComponent } from './range-calendar.component';\nimport { format } from 'date-fns';\nimport { InputComponent } from './input.component';\nimport { DateRange } from './types/date-range';\n\n@Component({\n selector: 'tolle-date-range-picker',\n standalone: true,\n imports: [CommonModule, FormsModule, RangeCalendarComponent, InputComponent],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => DateRangePickerComponent),\n multi: true\n }\n ],\n template: `\n <div class=\"relative w-full\">\n <tolle-input\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [ngModel]=\"displayValue\"\n [class]=\"class\"\n >\n <div suffix class=\"flex items-center gap-1.5\">\n <button\n type=\"button\"\n tabindex=\"-1\"\n aria-label=\"Clear date range\"\n *ngIf=\"(value.start || value.end) && !disabled\"\n (click)=\"clear($event)\"\n class=\"ri-close-line cursor-pointer text-muted-foreground hover:text-foreground transition-colors\"\n ></button>\n\n <button\n #trigger\n type=\"button\"\n (click)=\"togglePopover($event)\"\n aria-label=\"Open calendar\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"isOpen\"\n class=\"ri-calendar-line cursor-pointer text-muted-foreground hover:text-primary transition-colors bg-transparent border-0 p-0\"\n ></button>\n </div>\n </tolle-input>\n\n <div\n #popover\n *ngIf=\"isOpen\"\n role=\"dialog\"\n class=\"fixed z-50\"\n style=\"visibility: hidden;\"\n >\n <tolle-range-calendar class=\"shadow-lg\"\n [ngModel]=\"value\"\n (rangeSelect)=\"onCalendarSelect($event)\"\n [disablePastDates]=\"disablePastDates\"\n [numberOfMonths]=\"numberOfMonths\"\n ></tolle-range-calendar>\n </div>\n </div>\n `\n})\nexport class DateRangePickerComponent implements ControlValueAccessor, OnDestroy {\n @Input() disabled = false;\n @Input() placeholder = 'Pick a date range';\n @Input() class = '';\n @Input() disablePastDates = false;\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n /** Consecutive months shown in the popover (e.g. `2` for a two-month picker). @default 1 */\n @Input() numberOfMonths = 1;\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n\n value: DateRange = { start: null, end: null };\n isOpen = false;\n cleanupAutoUpdate?: () => void;\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.trigger.nativeElement.contains(event.target) && !this.popover?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get displayValue(): string {\n if (!this.value.start) return '';\n\n const startStr = format(this.value.start, 'MMM dd, yyyy');\n if (!this.value.end) return startStr;\n\n const endStr = format(this.value.end, 'MMM dd, yyyy');\n return `${startStr} - ${endStr}`;\n }\n\n onCalendarSelect(range: DateRange) {\n this.value = range;\n this.onChange(this.value);\n\n // Close only if range is complete\n if (range.start && range.end) {\n setTimeout(() => this.close(), 150);\n }\n }\n\n togglePopover(event: MouseEvent) {\n event.stopPropagation();\n if (this.disabled) return;\n this.isOpen ? this.close() : this.open();\n }\n\n @HostListener('keydown.escape') onEscape() { if (this.isOpen) this.close(); }\n\n open() {\n this.isOpen = true;\n // Render the popover synchronously so its DOM node + @ViewChild('popover') exist\n // before we position it. Otherwise, with zone eventCoalescing (Angular defers change\n // detection to the next animation frame), updatePosition() can run first, find no\n // popover, and the calendar stays hidden — regardless of where the picker is used.\n this.cdr.detectChanges();\n if (this.trigger?.nativeElement) {\n this.trigger.nativeElement.focus();\n }\n const schedule = typeof requestAnimationFrame !== 'undefined' ? requestAnimationFrame : (fn: FrameRequestCallback) => setTimeout(fn, 0);\n schedule(() => {\n this.updatePosition();\n document.addEventListener('mousedown', this._outsideClickHandler);\n });\n }\n\n close() {\n this.isOpen = false;\n if (this.cleanupAutoUpdate) {\n this.cleanupAutoUpdate();\n this.cleanupAutoUpdate = undefined;\n }\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n clear(event: MouseEvent) {\n event.stopPropagation();\n this.value = { start: null, end: null };\n this.onChange(this.value);\n this.cdr.markForCheck();\n }\n\n // --- Floating UI Positioning with Fixed Strategy ---\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanupAutoUpdate = autoUpdate(\n this.trigger.nativeElement,\n this.popover.nativeElement,\n () => {\n computePosition(this.trigger.nativeElement, this.popover.nativeElement, {\n placement: 'bottom-end',\n strategy: 'fixed', // Use fixed to escape column layout\n middleware: [\n offset(4),\n flip({\n fallbackAxisSideDirection: 'start',\n padding: 8\n }),\n shift({ padding: 8 }),\n size({\n apply({ rects, elements, availableHeight }) {\n // Constrain popover to available space\n Object.assign(elements.floating.style, {\n maxHeight: `${Math.min(400, availableHeight)}px`,\n minWidth: `${Math.max(rects.reference.width, 320)}px`, // Calendar minimum width\n });\n }\n })\n ],\n }).then(({ x, y, placement }) => {\n Object.assign(this.popover.nativeElement.style, {\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n\n // Optional: Add placement class for styling\n this.popover.nativeElement.classList.remove('calendar-top', 'calendar-bottom');\n if (placement.includes('top')) {\n this.popover.nativeElement.classList.add('calendar-top');\n } else {\n this.popover.nativeElement.classList.add('calendar-bottom');\n }\n });\n }\n );\n }\n\n ngOnDestroy() {\n if (this.cleanupAutoUpdate) this.cleanupAutoUpdate();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n @HostListener('window:resize')\n onWindowResize() {\n if (this.isOpen) {\n this.close(); // Close on resize for simplicity\n }\n }\n\n @HostListener('window:scroll')\n onWindowScroll() {\n // Floating-UI's autoUpdate handles scroll repositioning\n }\n\n // --- Control Value Accessor ---\n onChange: any = () => {};\n onTouched: any = () => {};\n\n writeValue(val: DateRange | null): void {\n if (val) {\n this.value = { ...val };\n } else {\n this.value = { start: null, end: null };\n }\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; }\n\n protected cn = cn;\n}\n"
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
"path": "types/date-range.ts",
|
|
23
|
+
"target": "ui/types/date-range.ts",
|
|
24
|
+
"content": "export type DateRange = {\n start: Date | null;\n end: Date | null;\n}\n"
|
|
25
|
+
},
|
|
26
|
+
{
|
|
27
|
+
"path": "utils/cn.ts",
|
|
28
|
+
"target": "ui/utils/cn.ts",
|
|
29
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
30
|
+
}
|
|
31
|
+
]
|
|
32
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dropdown-item",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Dropdown Item",
|
|
5
|
+
"description": "Dropdown Item component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "dropdown-item.component.ts",
|
|
12
|
+
"target": "ui/dropdown-item.component.ts",
|
|
13
|
+
"content": "import {Component, Input} from '@angular/core';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-dropdown-item',\n standalone: true,\n imports: [],\n template: `\n <div\n role=\"menuitem\"\n tabindex=\"-1\"\n [attr.data-disabled]=\"disabled ? '' : null\"\n [attr.aria-disabled]=\"disabled || null\"\n [class]=\"cn('relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground focus-visible:outline-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50', class)\">\n <ng-content></ng-content>\n </div>\n `,\n styles: ``\n})\nexport class DropdownItemComponent {\n @Input() class: string = '';\n @Input() disabled = false;\n protected readonly cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dropdown-label",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Dropdown Label",
|
|
5
|
+
"description": "Dropdown Label component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "dropdown-label.component.ts",
|
|
12
|
+
"target": "ui/dropdown-label.component.ts",
|
|
13
|
+
"content": "import { Component } from '@angular/core';\n\n@Component({\n selector: 'tolle-dropdown-label',\n standalone: true,\n imports: [],\n template: `\n <div class=\"px-2 py-1.5 text-sm font-semibold text-muted-foreground\">\n <ng-content></ng-content>\n </div>\n `,\n styles: ``\n})\nexport class DropdownLabelComponent {\n\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dropdown-menu",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Dropdown Menu",
|
|
5
|
+
"description": "Dropdown Menu component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "dropdown-menu.component.ts",
|
|
12
|
+
"target": "ui/dropdown-menu.component.ts",
|
|
13
|
+
"content": "import {Component, TemplateRef, ViewChild} from '@angular/core';\n\n@Component({\n selector: 'tolle-dropdown-menu',\n standalone: true,\n imports: [],\n template: `\n <ng-template #menuTemplate>\n <div role=\"menu\" aria-orientation=\"vertical\" class=\"z-[1000] min-w-[12rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md animate-in fade-in zoom-in-95\">\n <ng-content></ng-content>\n </div>\n </ng-template>\n `,\n styles: ``\n})\nexport class DropdownMenuComponent {\n @ViewChild('menuTemplate') templateRef!: TemplateRef<any>;\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dropdown-separator",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Dropdown Separator",
|
|
5
|
+
"description": "Dropdown Separator component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "dropdown-separator.component.ts",
|
|
12
|
+
"target": "ui/dropdown-separator.component.ts",
|
|
13
|
+
"content": "import { Component } from '@angular/core';\n\n@Component({\n selector: 'tolle-dropdown-separator',\n standalone: true,\n imports: [],\n template: `<div role=\"separator\" aria-hidden=\"true\" class=\"-mx-1 my-1 h-px bg-border\"></div>`,\n styles: ``\n})\nexport class DropdownSeparatorComponent {\n\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dropdown-trigger",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Dropdown Trigger",
|
|
5
|
+
"description": "Dropdown Trigger component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"dropdown-menu"
|
|
9
|
+
],
|
|
10
|
+
"dependencies": [
|
|
11
|
+
"@floating-ui/dom"
|
|
12
|
+
],
|
|
13
|
+
"files": [
|
|
14
|
+
{
|
|
15
|
+
"path": "dropdown-trigger.directive.ts",
|
|
16
|
+
"target": "ui/dropdown-trigger.directive.ts",
|
|
17
|
+
"content": "import { Directive, Input, ElementRef, OnDestroy, HostListener, ViewContainerRef } from '@angular/core';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { DropdownMenuComponent } from './dropdown-menu.component';\n\n@Directive({\n selector: '[tolleDropdownTrigger]',\n standalone: true,\n host: {\n '[attr.aria-haspopup]': \"'menu'\",\n '[attr.aria-expanded]': 'isOpen'\n }\n})\nexport class DropdownTriggerDirective implements OnDestroy {\n @Input('tolleDropdownTrigger') menu!: DropdownMenuComponent;\n\n private cleanup?: () => void;\n protected isOpen = false;\n private menuElement?: HTMLElement;\n\n constructor(\n private el: ElementRef,\n private vcr: ViewContainerRef\n ) {}\n\n @HostListener('click')\n toggle() {\n this.isOpen ? this.close() : this.open();\n }\n\n private open() {\n this.isOpen = true;\n\n // Create the menu view\n const view = this.vcr.createEmbeddedView(this.menu.templateRef);\n this.menuElement = view.rootNodes[0] as HTMLElement;\n document.body.appendChild(this.menuElement);\n\n // Floating UI positioning logic\n this.cleanup = autoUpdate(this.el.nativeElement, this.menuElement, () => {\n computePosition(this.el.nativeElement, this.menuElement!, {\n placement: 'bottom-end',\n middleware: [\n offset(4), // Space between trigger and menu\n flip(), // Flip to top if space is tight\n shift({ padding: 8 }) // Prevent menu from hitting screen edges\n ]\n }).then(({ x, y }) => {\n Object.assign(this.menuElement!.style, {\n left: `${x}px`,\n top: `${y}px`,\n position: 'absolute'\n });\n });\n });\n\n // Close on outside click\n setTimeout(() => {\n document.addEventListener('click', this.outsideClick);\n });\n }\n\n private close() {\n this.isOpen = false;\n this.cleanup?.();\n this.menuElement?.remove();\n document.removeEventListener('click', this.outsideClick);\n }\n\n private outsideClick = (event: MouseEvent) => {\n if (!this.el.nativeElement.contains(event.target) && !this.menuElement?.contains(event.target as Node)) {\n this.close();\n }\n };\n\n ngOnDestroy() {\n this.close();\n }\n}\n"
|
|
18
|
+
}
|
|
19
|
+
]
|
|
20
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "empty-state",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Empty State",
|
|
5
|
+
"description": "Empty State component with variant options.",
|
|
6
|
+
"category": "feedback",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"class-variance-authority"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "empty-state.component.ts",
|
|
14
|
+
"target": "ui/empty-state.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 emptyStateVariants = cva(\n \"flex flex-col items-center justify-center text-center animate-in fade-in duration-500\",\n {\n variants: {\n variant: {\n default: \"min-h-[400px] rounded-md border border-dashed border-border p-8 bg-background/50\",\n minimal: \"p-4 min-h-[200px]\",\n },\n },\n defaultVariants: {\n variant: \"default\",\n },\n }\n);\n\ntype EmptyStateVariants = VariantProps<typeof emptyStateVariants>;\n\n@Component({\n selector: 'tolle-empty-state',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"cn(emptyStateVariants({ variant }), class)\">\n\n <div [class]=\"cn(\n 'flex items-center justify-center rounded-full bg-muted',\n variant === 'minimal' ? 'h-12 w-12' : 'h-20 w-20'\n )\">\n <ng-content select=\"[icon]\">\n <i aria-hidden=\"true\" [class]=\"cn(\n 'ri-inbox-line text-muted-foreground/60',\n variant === 'minimal' ? 'text-xl' : 'text-3xl'\n )\"></i>\n </ng-content>\n </div>\n\n <h3 [class]=\"cn(\n 'font-semibold text-foreground',\n variant === 'minimal' ? 'mt-2 text-sm' : 'mt-4 text-lg'\n )\">\n {{ title }}\n </h3>\n\n <p *ngIf=\"description\" [class]=\"cn(\n 'text-muted-foreground',\n variant === 'minimal' ? 'mt-1 text-xs' : 'mb-6 mt-2 max-w-sm text-sm'\n )\">\n {{ description }}\n </p>\n\n <div *ngIf=\"variant !== 'minimal'\" class=\"flex items-center justify-center gap-3\">\n <ng-content select=\"[actions]\"></ng-content>\n </div>\n </div>\n `\n})\nexport class EmptyStateComponent {\n @Input() variant: EmptyStateVariants['variant'] = 'default';\n @Input() title: string = 'No items found';\n @Input() description?: string;\n @Input() class: string = '';\n\n protected emptyStateVariants = emptyStateVariants;\n protected cn = cn;\n}\n"
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
"path": "utils/cn.ts",
|
|
19
|
+
"target": "ui/utils/cn.ts",
|
|
20
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
21
|
+
}
|
|
22
|
+
]
|
|
23
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hover-card",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Hover Card",
|
|
5
|
+
"description": "Hover Card component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [
|
|
9
|
+
"@floating-ui/dom"
|
|
10
|
+
],
|
|
11
|
+
"files": [
|
|
12
|
+
{
|
|
13
|
+
"path": "hover-card.component.ts",
|
|
14
|
+
"target": "ui/hover-card.component.ts",
|
|
15
|
+
"content": "import { Component, Input, Output, EventEmitter, inject, TemplateRef, ViewChild, ViewContainerRef, OnDestroy, ContentChild, forwardRef, ElementRef, AfterViewInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-hover-card',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`\n})\nexport class HoverCardComponent implements OnDestroy {\n @Input() openDelay = 700;\n @Input() closeDelay = 300;\n @Input() placement: 'top' | 'bottom' | 'left' | 'right' = 'bottom';\n\n private openTimeout?: any;\n private closeTimeout?: any;\n\n isOpen = false;\n\n @ContentChild(forwardRef(() => HoverCardContentComponent)) contentComponent?: HoverCardContentComponent;\n\n open(triggerElement: HTMLElement) {\n if (this.closeTimeout) {\n clearTimeout(this.closeTimeout);\n this.closeTimeout = undefined;\n }\n\n if (this.isOpen) return;\n\n this.openTimeout = setTimeout(() => {\n this.show(triggerElement);\n }, this.openDelay);\n }\n\n close() {\n if (this.openTimeout) {\n clearTimeout(this.openTimeout);\n this.openTimeout = undefined;\n }\n\n this.closeTimeout = setTimeout(() => {\n this.hide();\n }, this.closeDelay);\n }\n\n private show(triggerElement: HTMLElement) {\n this.isOpen = true;\n setTimeout(() => {\n if (this.contentComponent) {\n this.contentComponent.show(triggerElement, this.placement);\n }\n });\n }\n\n private hide() {\n this.isOpen = false;\n if (this.contentComponent) {\n this.contentComponent.hide();\n }\n }\n\n ngOnDestroy() {\n this.hide();\n if (this.openTimeout) clearTimeout(this.openTimeout);\n if (this.closeTimeout) clearTimeout(this.closeTimeout);\n }\n}\n\n@Component({\n selector: 'tolle-hover-card-trigger',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n '(mouseenter)': 'onMouseEnter()',\n '(mouseleave)': 'onMouseLeave()',\n '(focusin)': 'onMouseEnter()',\n '(focusout)': 'onMouseLeave()',\n '[class]': '\"cursor-default inline-block\"'\n }\n})\nexport class HoverCardTriggerComponent {\n private hoverCard = inject(HoverCardComponent);\n private elementRef = inject(ElementRef);\n\n onMouseEnter() {\n this.hoverCard.open(this.elementRef.nativeElement);\n }\n\n onMouseLeave() {\n this.hoverCard.close();\n }\n}\n\n@Component({\n selector: 'tolle-hover-card-content',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div *ngIf=\"isVisible\"\n #content\n [class]=\"computedClass\"\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\">\n <ng-content></ng-content>\n </div>\n `\n})\nexport class HoverCardContentComponent implements OnDestroy {\n @Input() class: string = '';\n @ViewChild('content') contentEl?: ElementRef;\n\n isVisible = false;\n private cleanup?: () => void;\n private hoverCard = inject(HoverCardComponent);\n\n show(triggerElement: HTMLElement, placement: any) {\n this.isVisible = true;\n setTimeout(() => {\n if (this.contentEl) {\n this.updatePosition(triggerElement, this.contentEl.nativeElement, placement);\n }\n });\n }\n\n hide() {\n this.isVisible = false;\n if (this.cleanup) {\n this.cleanup();\n this.cleanup = undefined;\n }\n }\n\n private updatePosition(trigger: HTMLElement, content: HTMLElement, placement: any) {\n this.cleanup = autoUpdate(trigger, content, () => {\n computePosition(trigger, content, {\n placement,\n middleware: [offset(8), flip(), shift({ padding: 8 })],\n }).then(({ x, y }) => {\n Object.assign(content.style, {\n position: 'fixed',\n left: `${x}px`,\n top: `${y}px`,\n zIndex: '9999'\n });\n });\n });\n }\n\n onMouseEnter() {\n this.hoverCard.open(null as any);\n }\n\n onMouseLeave() {\n this.hoverCard.close();\n }\n\n get computedClass() {\n return cn(\n \"w-64 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95\",\n this.class\n );\n }\n\n ngOnDestroy() {\n if (this.cleanup) this.cleanup();\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": "input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Input",
|
|
5
|
+
"description": "Input component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "input.component.ts",
|
|
12
|
+
"target": "ui/input.component.ts",
|
|
13
|
+
"content": "import { Component, Input, forwardRef, ChangeDetectorRef, ElementRef, ViewChild, 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-input',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => InputComponent),\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 <div class=\"h-full flex items-center text-muted-foreground group-focus-within:text-primary transition-colors duration-200\">\n <ng-content select=\"[prefix]\"></ng-content>\n </div>\n\n <input\n #inputElement\n [id]=\"id\"\n [type]=\"type\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [(ngModel)]=\"value\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n (input)=\"onInputChange($event)\"\n [class]=\"computedInputClass\"\n [attr.aria-invalid]=\"error || null\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : (hint ? id + '-hint' : null)\"\n />\n\n <div class=\"h-full flex items-center text-muted-foreground group-focus-within:text-primary transition-colors duration-200\">\n <ng-content select=\"[suffix]\"></ng-content>\n </div>\n </div>\n\n <ng-container *ngIf=\"!disabled\">\n <p\n *ngIf=\"hint && !error\"\n [id]=\"id + '-hint'\"\n class=\"text-xs text-muted-foreground px-1 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 px-1\"\n >\n {{ errorMessage }}\n </p>\n </ng-container>\n </div>\n `,\n})\nexport class InputComponent implements ControlValueAccessor, AfterViewInit {\n @ViewChild('inputElement') inputElement!: ElementRef<HTMLInputElement>;\n\n @Input() id: string = `input-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label: string = '';\n @Input() hint: string = '';\n @Input() errorMessage: string = '';\n @Input() type: string = 'text';\n @Input() placeholder: string = '';\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() containerClass: string = '';\n @Input() class: string = '';\n\n // New States\n @Input() disabled: boolean = false;\n @Input() readonly: boolean = false;\n @Input() error: boolean = false;\n\n // Focus behavior\n @Input() hideHintOnFocus: boolean = true;\n\n value: any = '';\n onChange: any = () => { };\n onTouched: any = () => { };\n isFocused: boolean = false;\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n ngAfterViewInit() {\n if (this.inputElement?.nativeElement.hasAttribute('autofocus')) {\n setTimeout(() => this.inputElement.nativeElement.focus());\n }\n }\n\n writeValue(value: any): void {\n this.value = value;\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 onInputChange(event: Event): void {\n if (this.readonly || this.disabled) return;\n const val = (event.target as HTMLInputElement).value;\n this.value = val;\n this.onChange(val);\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n }\n\n focusInput(): void {\n if (!this.disabled && this.inputElement) {\n this.inputElement.nativeElement.focus();\n }\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\n \"group relative flex 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\n this.size === 'xs' && \"h-8 px-2 gap-1.5 text-xs\",\n this.size === 'sm' && \"h-9 px-3 gap-2 text-sm\",\n this.size === 'default' && \"h-10 px-3 gap-2 text-sm\",\n this.size === 'lg' && \"h-11 px-4 gap-3 text-base\",\n\n // Focus state - SIMPLE AND ELEGANT LIKE ZARDUI\n // The magic happens in CSS: border darkens automatically on focus\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.containerClass\n );\n }\n\n get computedInputClass() {\n return cn(\n // Base styles\n \"flex-1 bg-transparent border-none p-0\",\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\n this.class\n );\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "label",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Label",
|
|
5
|
+
"description": "Label component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "label.component.ts",
|
|
12
|
+
"target": "ui/label.component.ts",
|
|
13
|
+
"content": "import { Component, Input } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-label',\n standalone: true,\n imports: [CommonModule],\n template: `\n <label\n [attr.for]=\"for\"\n [class]=\"computedClass\"\n >\n <ng-content></ng-content>\n </label>\n `\n})\nexport class LabelComponent {\n @Input() for?: string;\n @Input() class: string = '';\n\n get computedClass() {\n return cn(\n \"inline-flex items-center gap-2 select-none text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-50\",\n this.class\n );\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "masked-input",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Masked Input",
|
|
5
|
+
"description": "Masked Input component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "masked-input.component.ts",
|
|
12
|
+
"target": "ui/masked-input.component.ts",
|
|
13
|
+
"content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChild,\n AfterContentChecked,\n ChangeDetectorRef,\n} 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-masked-input',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => MaskedInputComponent),\n multi: true,\n },\n ],\n template: `\n <div class=\"flex w-full flex-col gap-1.5\">\n <label *ngIf=\"label\" [for]=\"id\" [class]=\"computedLabelClass\">\n {{ label }}\n </label>\n\n <div [class]=\"computedContainerClass\" (click)=\"focusInput()\">\n <!-- Prefix Icon -->\n <div\n class=\"flex h-full items-center text-muted-foreground transition-colors duration-200 group-focus-within:text-primary\">\n <ng-content select=\"[prefix]\"></ng-content>\n </div>\n\n <input\n #inputEl\n [id]=\"id\"\n [type]=\"type\"\n [placeholder]=\"placeholder\"\n [disabled]=\"disabled\"\n [readOnly]=\"readonly\"\n [value]=\"displayValue\"\n (input)=\"onInput($event)\"\n (blur)=\"onBlur()\"\n (focus)=\"onFocus()\"\n [class]=\"computedInputClass\"\n [attr.aria-invalid]=\"error || null\"\n [attr.aria-describedby]=\"error && errorMessage ? id + '-error' : null\" />\n\n <!-- Suffix Icon -->\n <div\n class=\"flex h-full items-center text-muted-foreground transition-colors duration-200 group-focus-within:text-primary\">\n <ng-content select=\"[suffix]\"></ng-content>\n </div>\n </div>\n\n <ng-container *ngIf=\"!disabled\">\n <p\n *ngIf=\"hint && !error\"\n class=\"px-1 text-xs text-muted-foreground transition-opacity duration-200\"\n [class.opacity-0]=\"isFocused && hideHintOnFocus\">\n {{ hint }}\n </p>\n <p *ngIf=\"error && errorMessage\" [id]=\"id + '-error'\" class=\"px-1 text-xs text-destructive\">\n {{ errorMessage }}\n </p>\n </ng-container>\n </div>\n `,\n})\nexport class MaskedInputComponent implements ControlValueAccessor, AfterContentChecked {\n @Input() id: string = `masked-input-${Math.random().toString(36).substr(2, 9)}`;\n @Input() label: string = '';\n @Input() hint: string = '';\n @Input() errorMessage: string = '';\n @Input() mask: string = '';\n @Input() placeholder = '';\n @Input() type = 'text';\n @Input() disabled = false;\n @Input() readonly = false;\n @Input() class = '';\n @Input() containerClass: string = '';\n @Input() error: boolean = false;\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() returnRaw = false;\n @Input() hideHintOnFocus: boolean = true;\n @Input() externalFocused: boolean | undefined;\n\n @ViewChild('inputEl', { static: true }) inputEl!: ElementRef<HTMLInputElement>;\n\n hasPrefix = false;\n hasSuffix = false;\n displayValue = '';\n isFocused: boolean = false;\n\n private tokens: { [key: string]: RegExp } = {\n '0': /\\d/,\n '9': /\\d/,\n a: /[a-z]/i,\n A: /[a-z]/i,\n '*': /[a-z0-9]/i,\n };\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(\n private el: ElementRef,\n private cdr: ChangeDetectorRef\n ) {}\n\n ngAfterContentChecked() {\n const prefix = this.el.nativeElement.querySelector('[prefix]');\n const suffix = this.el.nativeElement.querySelector('[suffix]');\n\n if (this.hasPrefix !== !!prefix || this.hasSuffix !== !!suffix) {\n this.hasPrefix = !!prefix;\n this.hasSuffix = !!suffix;\n this.cdr.detectChanges();\n }\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 computedContainerClass() {\n const focused = this.externalFocused !== undefined ? this.externalFocused : this.isFocused;\n return cn(\n // Base styles\n 'group relative flex items-center w-full border transition-all duration-200',\n 'bg-background border-input shadow-sm',\n\n // Sizing\n this.size === 'xs' && 'h-8 px-2 gap-1.5 text-xs',\n this.size === 'sm' && 'h-9 px-3 gap-2 text-sm',\n this.size === 'default' && 'h-10 px-3 gap-2 text-sm',\n this.size === 'lg' && 'h-11 px-4 gap-3 text-base',\n\n // Rounded corners\n 'rounded-md',\n\n // Focus state\n !(this.readonly || this.disabled) && [\n 'focus-within:ring-4',\n 'focus-within:ring-ring/30',\n 'focus-within:ring-offset-0',\n 'focus-within:shadow-none',\n this.error ? 'focus-within:border-destructive/80' : 'focus-within:border-primary/80',\n ],\n\n // Error state\n this.error && [\n 'border-destructive',\n !(this.readonly || this.disabled) && 'focus-within:ring-destructive/30',\n ],\n\n // Disabled state\n this.disabled && ['cursor-not-allowed opacity-50', 'border-opacity-50'],\n\n // Readonly state\n this.readonly && ['cursor-default', 'border-dashed'],\n\n this.containerClass\n );\n }\n\n get computedInputClass() {\n return cn(\n // Base styles\n 'flex-1 bg-transparent border-none p-0',\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\n this.class\n );\n }\n\n focusInput(): void {\n if (!this.disabled && this.inputEl) {\n this.inputEl.nativeElement.focus();\n }\n }\n\n onFocus(): void {\n this.isFocused = true;\n }\n\n onBlur(): void {\n this.isFocused = false;\n this.onTouched();\n }\n\n // --- Masking Logic ---\n onInput(event: Event) {\n if (this.readonly || this.disabled) return;\n const input = event.target as HTMLInputElement;\n const raw = this.unmask(input.value);\n const masked = this.applyMask(raw);\n this.displayValue = masked;\n input.value = masked;\n this.returnRaw ? this.onChange(raw) : this.onChange(masked);\n }\n\n private applyMask(rawValue: string): string {\n let rawIndex = 0;\n let formatted = '';\n for (let i = 0; i < this.mask.length; i++) {\n if (rawIndex >= rawValue.length) break;\n const maskChar = this.mask[i];\n const rawChar = rawValue[rawIndex];\n if (this.tokens[maskChar]) {\n if (this.tokens[maskChar].test(rawChar)) {\n formatted += rawChar;\n rawIndex++;\n } else {\n rawIndex++;\n i--;\n }\n } else {\n formatted += maskChar;\n if (rawChar === maskChar) rawIndex++;\n }\n }\n return formatted;\n }\n\n private unmask(val: string): string {\n return val.replace(/[^a-zA-Z0-9]/g, '');\n }\n\n writeValue(value: any): void {\n this.displayValue = value ? this.applyMask(this.unmask(value.toString())) : '';\n this.cdr.markForCheck();\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 this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "utils/cn.ts",
|
|
17
|
+
"target": "ui/utils/cn.ts",
|
|
18
|
+
"content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "modal",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Modal",
|
|
5
|
+
"description": "Modal component.",
|
|
6
|
+
"category": "overlays",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "modal-ref.ts",
|
|
12
|
+
"target": "ui/modal-ref.ts",
|
|
13
|
+
"content": "import { OverlayRef } from '@angular/cdk/overlay';\nimport { Subject } from 'rxjs';\nimport { Modal } from './modal';\nimport { ModalStackService } from './modal-stack.service';\n\nexport class ModalRef<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 modal: Modal,\n private stack: ModalStackService\n ) {\n this.stack.register(this);\n\n // Handle Backdrop Click\n this.overlay.backdropClick().subscribe(() => {\n if (this.modal.backdropClose) {\n this.close();\n }\n });\n }\n\n /**\n * Closes the modal instantly.\n * @param result Data to pass back to the caller\n */\n close(result?: R): void {\n this._afterClosed$.next(result);\n this._afterClosed$.complete();\n this.overlay.dispose(); // Instant disposal (No animation timer)\n this.stack.unregister(this);\n }\n}\n"
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
"path": "modal-stack.service.ts",
|
|
17
|
+
"target": "ui/modal-stack.service.ts",
|
|
18
|
+
"content": "import { Injectable } from '@angular/core';\nimport { ModalRef } from './modal-ref';\n\n@Injectable({ providedIn: 'root' })\nexport class ModalStackService {\n private _stack = new Set<ModalRef>();\n\n register(ref: ModalRef): void {\n this._stack.add(ref);\n }\n\n unregister(ref: ModalRef): void {\n this._stack.delete(ref);\n }\n\n /** Instantly closes all open modals */\n closeAll(): void {\n this._stack.forEach(ref => ref.close());\n this._stack.clear();\n }\n\n get activeCount(): number {\n return this._stack.size;\n }\n}\n"
|
|
19
|
+
},
|
|
20
|
+
{
|
|
21
|
+
"path": "modal.component.ts",
|
|
22
|
+
"target": "ui/modal.component.ts",
|
|
23
|
+
"content": "import { Component, OnInit, TemplateRef, Type } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { ModalRef } from './modal-ref';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-modal',\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `\n <div [class]=\"modalClasses\" class=\"pointer-events-auto\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"ref.modal.title ? titleId : null\"\n [attr.aria-describedby]=\"contentType === 'string' ? descId : null\"\n cdkTrapFocus cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n\n <!-- Header -->\n <div *ngIf=\"hasHeader\" class=\"flex shrink-0 items-start justify-between gap-4 px-6 pt-6 pb-4\">\n <h2 *ngIf=\"ref.modal.title\" [id]=\"titleId\" class=\"text-lg font-semibold leading-none tracking-tight text-foreground\">\n {{ ref.modal.title }}\n </h2>\n <button\n *ngIf=\"ref.modal.showCloseButton\"\n type=\"button\"\n (click)=\"ref.close()\"\n aria-label=\"Close\"\n class=\"-mr-2 -mt-2 ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <i class=\"ri-close-line text-xl\"></i>\n </button>\n </div>\n\n <!-- Body -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n <ng-container [ngSwitch]=\"contentType\">\n <p *ngSwitchCase=\"'string'\" [id]=\"descId\" [class]=\"hasHeader ? 'px-6 pb-6 text-sm leading-relaxed text-muted-foreground' : 'p-6 text-sm leading-relaxed text-muted-foreground'\">{{ content }}</p>\n\n <ng-container *ngSwitchCase=\"'template'\">\n <ng-container *ngTemplateOutlet=\"asTemplate; context: ref.modal.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; /* This makes the host \"disappear\" so the div targets the overlay pane directly */\n }\n `]\n})\nexport class ModalComponent implements OnInit {\n contentType: 'template' | 'string' | 'component' = 'string';\n content: any;\n modalClasses = '';\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-modal-title-${this._uid}`;\n readonly descId = `tolle-modal-desc-${this._uid}`;\n\n constructor(public ref: ModalRef) {}\n\n ngOnInit() {\n this.content = this.ref.modal.content;\n this.modalClasses = this.getModalSizeCss();\n this.determineContentType();\n }\n\n /** Whether the auto-rendered header (title and/or close button) is shown. */\n get hasHeader(): boolean {\n return !!(this.ref.modal.showCloseButton || this.ref.modal.title);\n }\n\n private getModalSizeCss(): string {\n const { size } = this.ref.modal;\n\n return cn(\n // Surface: overlay card that never exceeds the viewport (header stays, body scrolls).\n 'bg-background text-foreground border border-border shadow-lg relative flex flex-col w-full mx-auto',\n\n size === 'fullscreen' ? 'h-screen w-screen rounded-none' : 'rounded-lg max-h-[85vh]',\n\n // Sizing scale with explicit max-widths\n size === 'xs' && 'max-w-[320px]',\n size === 'sm' && 'max-w-[425px]',\n size === 'default' && 'max-w-[512px]',\n size === 'lg' && 'max-w-[1024px]',\n size === 'xl' && 'max-w-[1280px]'\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 protected cn = cn;\n}\n"
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
"path": "modal.ts",
|
|
27
|
+
"target": "ui/modal.ts",
|
|
28
|
+
"content": "import { TemplateRef, Type } from \"@angular/core\";\n\nexport class Modal<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 /** * Predefined size scale.\n * - xs: 320px (Mobile alerts)\n * - sm: 425px (Standard dialogs)\n * - default: 544px (Forms)\n * - lg: 90% / 1024px (Data tables)\n * - xl: 1280px (Large forms, dashboards)\n * - fullscreen: 100vw/100vh (Complex workflows)\n */\n size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | 'fullscreen' = 'default';\n\n /** * If true (default), clicking the backdrop closes the modal.\n * Set to false for \"blocking\" modals (e.g., Terms of Service).\n */\n backdropClose?: boolean = true;\n\n /** * Data to pass to a Component content.\n * Accessed via @Input() in the child component.\n */\n data?: { [key: string]: any };\n\n /** * Context to pass to a TemplateRef content.\n * Accessed via `let-val` in the HTML.\n */\n context?: T;\n\n showCloseButton?: boolean = true;\n}\n"
|
|
29
|
+
},
|
|
30
|
+
{
|
|
31
|
+
"path": "utils/cn.ts",
|
|
32
|
+
"target": "ui/utils/cn.ts",
|
|
33
|
+
"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"
|
|
34
|
+
}
|
|
35
|
+
]
|
|
36
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "multi-select",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Multi Select",
|
|
5
|
+
"description": "Multi Select component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [
|
|
8
|
+
"badge",
|
|
9
|
+
"input",
|
|
10
|
+
"select-item"
|
|
11
|
+
],
|
|
12
|
+
"dependencies": [
|
|
13
|
+
"@floating-ui/dom",
|
|
14
|
+
"class-variance-authority"
|
|
15
|
+
],
|
|
16
|
+
"files": [
|
|
17
|
+
{
|
|
18
|
+
"path": "multi-select.component.ts",
|
|
19
|
+
"target": "ui/multi-select.component.ts",
|
|
20
|
+
"content": "import { Component, Input, ContentChildren, QueryList, AfterContentInit, ElementRef, ViewChild, OnDestroy } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate, size } from '@floating-ui/dom';\nimport { SelectItemComponent } from './select-item.component';\nimport { SelectService } from './select.service';\nimport { cn } from './utils/cn';\nimport { InputComponent } from './input.component';\nimport { BadgeComponent } from './badge.component';\n\n@Component({\n selector: 'tolle-multi-select',\n standalone: true,\n imports: [CommonModule, FormsModule, BadgeComponent, InputComponent],\n providers: [\n SelectService,\n { provide: NG_VALUE_ACCESSOR, useExisting: MultiSelectComponent, multi: true }\n ],\n template: `\n <div class=\"relative w-full\" #container>\n <button\n #trigger\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-activedescendant]=\"isOpen ? activeDescendantId : null\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\">\n\n <div class=\"flex flex-wrap gap-1 items-center max-w-[95%]\">\n <ng-container *ngIf=\"value.length; else placeholderTpl\">\n <tolle-badge *ngFor=\"let item of displayItems\" size=\"xs\" variant=\"secondary\" [removable]=\"true\" (onRemove)=\"removeValue($event, item.value)\">\n {{ item.label }}\n </tolle-badge>\n <span *ngIf=\"exceedsDisplayLimit\" class=\"text-xs text-muted-foreground px-1\">\n +{{ value.length - maxDisplayItems }} more\n </span>\n <span *ngIf=\"maxSelections && value.length >= maxSelections\" class=\"text-xs text-muted-foreground px-1\">\n (Max reached)\n </span>\n </ng-container>\n <ng-template #placeholderTpl><span class=\"text-muted-foreground\">{{ placeholder }}</span></ng-template>\n </div>\n <i [class]=\"cn('ri-arrow-down-s-line text-muted-foreground ml-2 transition-transform duration-200', isOpen ? 'rotate-180' : '')\"></i>\n </button>\n\n <div #popover *ngIf=\"isOpen\"\n role=\"listbox\"\n aria-multiselectable=\"true\"\n class=\"fixed bg-popover text-popover-foreground z-[999] rounded-md border border-border shadow-md overflow-hidden\"\n style=\"visibility: hidden; top: 0; left: 0;\">\n\n <div class=\"p-2 border-b border-border space-y-2 bg-popover\">\n <div class=\"flex items-center justify-between px-1 text-xs\">\n <span class=\"text-muted-foreground\">\n {{ value.length }} selected\n <span *ngIf=\"maxSelections\">/ {{ maxSelections }} max</span>\n </span>\n <span *ngIf=\"maxSelections && value.length >= maxSelections\" class=\"text-destructive text-xs font-medium\">\n Maximum reached\n </span>\n </div>\n\n <tolle-input *ngIf=\"searchable\" #searchInput size=\"xs\" placeholder=\"Search...\" [(ngModel)]=\"searchQuery\" (ngModelChange)=\"onSearchChange($event)\" (keydown)=\"onSearchKeyDown($event)\">\n <i prefix class=\"ri-search-line\"></i>\n </tolle-input>\n\n <div class=\"flex items-center justify-between px-1\">\n <button type=\"button\"\n (click)=\"selectAll()\"\n [disabled]=\"!!(maxSelections && selectableItems.length > maxSelections)\"\n [class]=\"cn(\n 'text-[10px] font-bold uppercase transition-colors',\n maxSelections && selectableItems.length > maxSelections\n ? 'text-muted-foreground cursor-not-allowed'\n : 'text-primary hover:underline'\n )\">\n Select All\n </button>\n <button type=\"button\" (click)=\"clearAll()\" class=\"text-[10px] font-bold uppercase text-muted-foreground hover:underline\">\n Clear\n </button>\n </div>\n </div>\n\n <div class=\"p-1 max-h-60 overflow-y-auto\">\n <ng-content></ng-content>\n <div *ngIf=\"noResults\" class=\"py-4 text-center text-xs text-muted-foreground\">\n No results found for \"{{searchQuery}}\"\n </div>\n <div *ngIf=\"maxSelections && value.length >= maxSelections\"\n class=\"p-2 text-center border-t border-border bg-muted/20\">\n <span class=\"text-xs text-destructive\">\n <i class=\"ri-alert-line mr-1\"></i>\n Maximum selection limit reached ({{maxSelections}})\n </span>\n </div>\n </div>\n </div>\n </div>\n `\n})\nexport class MultiSelectComponent implements ControlValueAccessor, AfterContentInit, OnDestroy {\n @Input() placeholder = 'Select options...';\n @Input() size: 'xs' | 'sm' | 'default' | 'lg' = 'default';\n @Input() searchable = false;\n @Input() disabled = false;\n @Input() class = '';\n @Input() maxSelections?: number;\n @Input() maxDisplayItems = 3;\n @Input() error = false; // Added to support error styling\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n @ViewChild('searchInput', { read: ElementRef }) searchInput?: ElementRef<HTMLElement>;\n @ContentChildren(SelectItemComponent, { descendants: true }) items!: QueryList<SelectItemComponent>;\n\n value: any[] = [];\n selectedItems: { label: string, value: any }[] = [];\n isOpen = false;\n searchQuery = '';\n noResults = false;\n private cleanup?: () => void;\n\n // Keyboard navigation (active-descendant) state\n activeIndex = -1;\n private typeaheadBuffer = '';\n private typeaheadTime = 0;\n private pendingTypeaheadChar = '';\n\n constructor(private selectService: SelectService) {\n this.selectService.selectedValue$.subscribe(val => {\n if (val !== undefined && this.isOpen) this.toggleValue(val);\n });\n }\n\n // NEW: Matches InputComponent styles exactly\n get computedTriggerClass() {\n return cn(\n // Base styles\n 'flex min-h-10 w-full items-center justify-between rounded-md border transition-all duration-200 h-auto',\n 'bg-background text-sm',\n\n // Border and shadow\n 'border-input shadow-sm',\n\n // Padding based on size (aligned with InputComponent logic)\n this.size === 'xs' && 'px-2 py-1',\n this.size === 'sm' && 'px-3 py-1.5',\n this.size === 'default' && 'px-3 py-2',\n this.size === 'lg' && 'px-4 py-2',\n\n // Focus state - ZARDUI STYLE (Soft ring, no offset)\n !this.disabled && [\n 'focus-visible:outline-none',\n 'focus-visible:ring-4',\n 'focus-visible:ring-ring/30',\n 'focus-visible:ring-offset-0',\n 'focus-visible:shadow-none',\n 'focus-visible:border-primary/80' // Darkens border on focus\n ],\n\n // Hover state\n !this.disabled && 'hover:border-accent',\n\n // Error state\n this.error && [\n 'border-destructive',\n !this.disabled && [\n 'focus-visible:border-destructive/80',\n 'focus-visible: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 this.class\n );\n }\n\n ngAfterContentInit() {\n this.syncItems();\n this.items.changes.subscribe(() => this.syncItems());\n }\n\n get displayItems(): { label: string, value: any }[] {\n return this.selectedItems.slice(0, this.maxDisplayItems);\n }\n\n get exceedsDisplayLimit(): boolean {\n return this.value.length > this.maxDisplayItems;\n }\n\n get selectableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(item => !item.disabled) : [];\n }\n\n get availableSelections(): number {\n if (!this.maxSelections) return Infinity;\n return Math.max(0, this.maxSelections - this.value.length);\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (!this.trigger.nativeElement.contains(event.target) && !this.popover?.nativeElement.contains(event.target)) {\n this.close();\n }\n };\n\n toggle() {\n if (this.disabled) return;\n this.isOpen ? this.close() : this.open();\n }\n\n open() {\n this.isOpen = true;\n setTimeout(() => {\n this.updatePosition();\n document.addEventListener('mousedown', this._outsideClickHandler);\n this.initActiveAfterOpen();\n });\n }\n\n close() {\n this.isOpen = false;\n this.searchQuery = '';\n this.onSearchChange('');\n this.resetActive();\n if (this.cleanup) this.cleanup();\n document.removeEventListener('mousedown', this._outsideClickHandler);\n }\n\n private updatePosition() {\n if (!this.trigger || !this.popover) return;\n\n this.cleanup = autoUpdate(this.trigger.nativeElement, this.popover.nativeElement, () => {\n computePosition(this.trigger.nativeElement, this.popover.nativeElement, {\n strategy: 'fixed',\n placement: 'bottom-start',\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 }),\n size({\n apply({ rects, elements, availableHeight }) {\n Object.assign(elements.floating.style, {\n width: `${rects.reference.width}px`,\n maxHeight: `${availableHeight}px`\n });\n },\n }),\n ],\n }).then(({ x, y, strategy }) => {\n Object.assign(this.popover.nativeElement.style, {\n position: strategy,\n left: `${x}px`,\n top: `${y}px`,\n visibility: 'visible',\n });\n });\n });\n }\n\n toggleValue(val: any) {\n const index = this.value.indexOf(val);\n if (index > -1) {\n this.value.splice(index, 1);\n } else {\n if (this.maxSelections && this.value.length >= this.maxSelections) return;\n this.value.push(val);\n }\n this.syncItems();\n this.onChange([...this.value]);\n }\n\n selectAll() {\n if (!this.items) return;\n let itemsToSelect: any[] = [];\n if (this.maxSelections) {\n const availableItems = this.items\n .filter(item => !item.disabled && !this.value.includes(item.value))\n .slice(0, this.availableSelections)\n .map(item => item.value);\n itemsToSelect = [...this.value, ...availableItems];\n } else {\n itemsToSelect = this.items.filter(item => !item.disabled).map(item => item.value);\n }\n this.value = itemsToSelect;\n this.syncItems();\n this.onChange([...this.value]);\n }\n\n clearAll() {\n this.value = [];\n this.syncItems();\n this.onChange([]);\n }\n\n removeValue(event: MouseEvent, val: any) {\n event.stopPropagation();\n this.toggleValue(val);\n }\n\n private syncItems() {\n if (!this.items) return;\n this.selectedItems = [];\n this.items.forEach(item => {\n item.selected = this.value.includes(item.value);\n if (item.selected) {\n this.selectedItems.push({ label: item.getLabel(), value: item.value });\n }\n if (this.maxSelections && this.value.length >= this.maxSelections) {\n item.disabled = !this.value.includes(item.value);\n } else if (item.disabled) {\n item.disabled = false;\n }\n });\n }\n\n onSearchChange(q: string) {\n const filter = (q || '').toLowerCase();\n let visibleCount = 0;\n this.items.forEach(item => {\n const isVisible = item.getLabel().toLowerCase().includes(filter);\n item.hidden = !isVisible;\n if (isVisible) visibleCount++;\n });\n this.noResults = visibleCount === 0 && filter !== '';\n\n // Re-anchor the active option to the first visible match while open.\n if (this.isOpen) this.setActive(0);\n }\n\n // ---- Keyboard navigation (WAI-ARIA listbox pattern) ----\n\n private get navigableItems(): SelectItemComponent[] {\n return this.items ? this.items.filter(i => !i.hidden && !i.disabled) : [];\n }\n\n get activeDescendantId(): string | null {\n const list = this.navigableItems;\n return this.activeIndex >= 0 && this.activeIndex < list.length\n ? list[this.activeIndex].id\n : null;\n }\n\n private updateActiveStates() {\n const list = this.navigableItems;\n const activeItem = this.activeIndex >= 0 ? list[this.activeIndex] ?? null : null;\n this.items?.forEach(i => (i.active = i === activeItem));\n this.syncActiveDescendant();\n }\n\n private syncActiveDescendant() {\n const input = this.searchInput?.nativeElement?.querySelector('input');\n if (!input) return;\n const id = this.activeDescendantId;\n if (id) input.setAttribute('aria-activedescendant', id);\n else input.removeAttribute('aria-activedescendant');\n }\n\n private setActive(index: number) {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n this.activeIndex = Math.max(0, Math.min(index, list.length - 1));\n this.updateActiveStates();\n list[this.activeIndex]?.scrollIntoActiveView();\n }\n\n private setActiveItem(item: SelectItemComponent) {\n const idx = this.navigableItems.indexOf(item);\n if (idx >= 0) this.setActive(idx);\n }\n\n private moveActive(delta: number) {\n const list = this.navigableItems;\n if (!list.length) return;\n const next = this.activeIndex < 0 ? (delta > 0 ? 0 : list.length - 1) : this.activeIndex + delta;\n this.setActive(next); // setActive clamps (no wrap, matching shadcn)\n }\n\n private setActiveToSelectedOrFirst() {\n const list = this.navigableItems;\n if (!list.length) {\n this.activeIndex = -1;\n this.updateActiveStates();\n return;\n }\n const selIdx = list.findIndex(i => this.value.includes(i.value));\n this.setActive(selIdx >= 0 ? selIdx : 0);\n }\n\n private initActiveAfterOpen() {\n if (this.pendingTypeaheadChar) {\n const c = this.pendingTypeaheadChar;\n this.pendingTypeaheadChar = '';\n this.setActiveToSelectedOrFirst();\n this.typeahead(c);\n } else {\n this.setActiveToSelectedOrFirst();\n }\n }\n\n private resetActive() {\n this.activeIndex = -1;\n this.typeaheadBuffer = '';\n this.pendingTypeaheadChar = '';\n this.items?.forEach(i => (i.active = false));\n }\n\n private isTypeaheadChar(event: KeyboardEvent): boolean {\n return event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;\n }\n\n private typeahead(char: string) {\n const now = Date.now();\n // Reset the buffer based on elapsed time (correctness is not timer-driven).\n if (now - this.typeaheadTime > 500) this.typeaheadBuffer = '';\n this.typeaheadTime = now;\n this.typeaheadBuffer += char.toLowerCase();\n const match = this.navigableItems.find(i =>\n i.getLabel().toLowerCase().startsWith(this.typeaheadBuffer)\n );\n if (match) this.setActiveItem(match);\n }\n\n private selectActive() {\n const item = this.navigableItems[this.activeIndex];\n if (!item) return;\n // Multi-select: mirror SelectItemComponent.onClick — toggle and stay open.\n item.selected = !item.selected;\n this.selectService.registerClick(item.value, item.getLabel());\n // syncItems() ran synchronously via the service subscription; keep the\n // same option highlighted (its index may have shifted).\n this.setActiveItem(item);\n }\n\n onTriggerKeyDown(event: KeyboardEvent) {\n if (this.disabled) return;\n const key = event.key;\n\n if (!this.isOpen) {\n if (key === 'ArrowDown' || key === 'ArrowUp' || key === 'Enter' || key === ' ' || key === 'Home' || key === 'End') {\n event.preventDefault();\n this.open();\n return;\n }\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.pendingTypeaheadChar = key;\n this.open();\n }\n return;\n }\n\n switch (key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Home': event.preventDefault(); this.setActive(0); break;\n case 'End': event.preventDefault(); this.setActive(this.navigableItems.length - 1); break;\n case 'Enter':\n case ' ':\n event.preventDefault();\n this.selectActive();\n break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n case 'Tab':\n this.close();\n break;\n default:\n if (this.isTypeaheadChar(event)) {\n event.preventDefault();\n this.typeahead(key);\n }\n }\n }\n\n // Keydown while the (optional) search input is focused. Arrows/Enter/Escape\n // drive the list; other keys fall through so the user can type to filter.\n onSearchKeyDown(event: KeyboardEvent) {\n if (!this.isOpen) return;\n switch (event.key) {\n case 'ArrowDown': event.preventDefault(); this.moveActive(1); break;\n case 'ArrowUp': event.preventDefault(); this.moveActive(-1); break;\n case 'Enter': event.preventDefault(); this.selectActive(); break;\n case 'Escape':\n event.preventDefault();\n this.close();\n this.trigger.nativeElement.focus();\n break;\n }\n }\n\n ngOnDestroy() {\n document.removeEventListener('mousedown', this._outsideClickHandler);\n if (this.cleanup) this.cleanup();\n }\n\n // ControlValueAccessor\n onChange: any = () => { };\n onTouched: any = () => { };\n writeValue(v: any[]): void {\n this.value = Array.isArray(v) ? v : [];\n this.syncItems();\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; }\n\n protected cn = cn;\n}\n"
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
"path": "select.service.ts",
|
|
24
|
+
"target": "ui/select.service.ts",
|
|
25
|
+
"content": "import { Injectable } from '@angular/core';\nimport { Subject } from 'rxjs';\n\n@Injectable()\nexport class SelectService {\n // Emits the value of the clicked item\n private selectedValueSource = new Subject<any>();\n selectedValue$ = this.selectedValueSource.asObservable();\n\n // Emits the label/text of the clicked item\n private selectedLabelSource = new Subject<string>();\n selectedLabel$ = this.selectedLabelSource.asObservable();\n\n registerClick(value: any, label: string) {\n this.selectedValueSource.next(value);\n this.selectedLabelSource.next(label);\n }\n}\n"
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
"path": "utils/cn.ts",
|
|
29
|
+
"target": "ui/utils/cn.ts",
|
|
30
|
+
"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"
|
|
31
|
+
}
|
|
32
|
+
]
|
|
33
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "otp-group",
|
|
3
|
+
"type": "registry:ui",
|
|
4
|
+
"title": "Otp Group",
|
|
5
|
+
"description": "Otp Group component.",
|
|
6
|
+
"category": "forms",
|
|
7
|
+
"registryDependencies": [],
|
|
8
|
+
"dependencies": [],
|
|
9
|
+
"files": [
|
|
10
|
+
{
|
|
11
|
+
"path": "otp-group.component.ts",
|
|
12
|
+
"target": "ui/otp-group.component.ts",
|
|
13
|
+
"content": "import { Component } from '@angular/core';\n\n@Component({\n selector: 'tolle-otp-group',\n standalone: true,\n imports: [],\n template: `\n <div class=\"flex items-center\"><ng-content></ng-content></div>\n `,\n styles: ``\n})\nexport class OtpGroupComponent {\n\n}\n"
|
|
14
|
+
}
|
|
15
|
+
]
|
|
16
|
+
}
|