@tolle_/tolle-ui 21.1.2 → 21.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (128) hide show
  1. package/fesm2022/tolle-ui.mjs +30099 -5285
  2. package/fesm2022/tolle-ui.mjs.map +1 -1
  3. package/package.json +17 -5
  4. package/preset.js +35 -1
  5. package/registry/docs-content.json +11486 -0
  6. package/registry/llms-full.txt +4067 -0
  7. package/registry/llms.txt +127 -0
  8. package/registry/manifest.json +12656 -0
  9. package/registry/r/accordion-item.json +21 -0
  10. package/registry/r/accordion.json +23 -0
  11. package/registry/r/alert-dialog-dynamic.json +31 -0
  12. package/registry/r/alert-dialog.json +26 -0
  13. package/registry/r/alert.json +23 -0
  14. package/registry/r/aspect-ratio.json +21 -0
  15. package/registry/r/attachment.json +23 -0
  16. package/registry/r/avatar-fallback.json +16 -0
  17. package/registry/r/avatar.json +23 -0
  18. package/registry/r/badge.json +23 -0
  19. package/registry/r/bar-list.json +21 -0
  20. package/registry/r/breadcrumb-item.json +16 -0
  21. package/registry/r/breadcrumb-link.json +16 -0
  22. package/registry/r/breadcrumb-separator.json +16 -0
  23. package/registry/r/breadcrumb.json +21 -0
  24. package/registry/r/bubble.json +23 -0
  25. package/registry/r/button-group.json +21 -0
  26. package/registry/r/button.json +23 -0
  27. package/registry/r/calendar.json +23 -0
  28. package/registry/r/card.json +21 -0
  29. package/registry/r/carousel.json +18 -0
  30. package/registry/r/category-bar.json +21 -0
  31. package/registry/r/chain-of-thought.json +23 -0
  32. package/registry/r/chart-pie.json +30 -0
  33. package/registry/r/chart-spark.json +30 -0
  34. package/registry/r/chart.json +28 -0
  35. package/registry/r/checkbox.json +23 -0
  36. package/registry/r/checkpoint.json +23 -0
  37. package/registry/r/collapsible.json +21 -0
  38. package/registry/r/combobox.json +31 -0
  39. package/registry/r/command-dialog.json +21 -0
  40. package/registry/r/command.json +26 -0
  41. package/registry/r/confirmation.json +23 -0
  42. package/registry/r/context-menu-trigger.json +23 -0
  43. package/registry/r/context-menu.json +28 -0
  44. package/registry/r/context.json +26 -0
  45. package/registry/r/conversation.json +23 -0
  46. package/registry/r/country-selector.json +31 -0
  47. package/registry/r/data-table.json +38 -0
  48. package/registry/r/date-picker.json +27 -0
  49. package/registry/r/date-range-picker.json +32 -0
  50. package/registry/r/date-time-picker.json +28 -0
  51. package/registry/r/direction.json +16 -0
  52. package/registry/r/dropdown-item.json +21 -0
  53. package/registry/r/dropdown-label.json +16 -0
  54. package/registry/r/dropdown-menu.json +16 -0
  55. package/registry/r/dropdown-separator.json +16 -0
  56. package/registry/r/dropdown-trigger.json +20 -0
  57. package/registry/r/empty-state.json +23 -0
  58. package/registry/r/field.json +23 -0
  59. package/registry/r/hover-card.json +23 -0
  60. package/registry/r/inline-citation.json +26 -0
  61. package/registry/r/input-group.json +23 -0
  62. package/registry/r/input.json +21 -0
  63. package/registry/r/item.json +23 -0
  64. package/registry/r/kbd.json +23 -0
  65. package/registry/r/label.json +21 -0
  66. package/registry/r/marker.json +23 -0
  67. package/registry/r/masked-input.json +21 -0
  68. package/registry/r/menubar.json +24 -0
  69. package/registry/r/message-scroller.json +26 -0
  70. package/registry/r/message.json +23 -0
  71. package/registry/r/modal.json +36 -0
  72. package/registry/r/model-selector.json +32 -0
  73. package/registry/r/multi-select.json +33 -0
  74. package/registry/r/native-select.json +23 -0
  75. package/registry/r/navigation-menu.json +24 -0
  76. package/registry/r/otp-group.json +16 -0
  77. package/registry/r/otp-slot.json +21 -0
  78. package/registry/r/otp.json +21 -0
  79. package/registry/r/pagination.json +33 -0
  80. package/registry/r/phone-number-input.json +34 -0
  81. package/registry/r/plan.json +23 -0
  82. package/registry/r/popover-content.json +21 -0
  83. package/registry/r/popover.json +18 -0
  84. package/registry/r/progress-circle.json +21 -0
  85. package/registry/r/progress.json +21 -0
  86. package/registry/r/prompt-input.json +23 -0
  87. package/registry/r/queue.json +23 -0
  88. package/registry/r/radio-group.json +26 -0
  89. package/registry/r/radio-item.json +26 -0
  90. package/registry/r/range-calendar.json +28 -0
  91. package/registry/r/reasoning.json +23 -0
  92. package/registry/r/resizable-panel-item.json +23 -0
  93. package/registry/r/resizable-panel.json +23 -0
  94. package/registry/r/resizable.json +21 -0
  95. package/registry/r/scroll-area.json +21 -0
  96. package/registry/r/segment.json +21 -0
  97. package/registry/r/select-group.json +21 -0
  98. package/registry/r/select-item.json +26 -0
  99. package/registry/r/select-separator.json +21 -0
  100. package/registry/r/select.json +32 -0
  101. package/registry/r/separator.json +21 -0
  102. package/registry/r/sheet-wrapper.json +31 -0
  103. package/registry/r/sheet.json +21 -0
  104. package/registry/r/shimmer.json +23 -0
  105. package/registry/r/sidebar.json +21 -0
  106. package/registry/r/skeleton.json +23 -0
  107. package/registry/r/slider.json +21 -0
  108. package/registry/r/sources.json +23 -0
  109. package/registry/r/spinner.json +23 -0
  110. package/registry/r/suggestion.json +23 -0
  111. package/registry/r/switch.json +23 -0
  112. package/registry/r/table.json +21 -0
  113. package/registry/r/tabs.json +23 -0
  114. package/registry/r/tag-input.json +25 -0
  115. package/registry/r/task.json +23 -0
  116. package/registry/r/textarea.json +21 -0
  117. package/registry/r/time-picker.json +24 -0
  118. package/registry/r/toaster.json +26 -0
  119. package/registry/r/toggle-group.json +25 -0
  120. package/registry/r/toggle.json +23 -0
  121. package/registry/r/tolle-cell.json +16 -0
  122. package/registry/r/tool.json +23 -0
  123. package/registry/r/tooltip.json +23 -0
  124. package/registry/r/tracker.json +25 -0
  125. package/registry/r/typography.json +23 -0
  126. package/registry/registry.json +2545 -0
  127. package/theme.css +230 -10
  128. package/types/tolle-ui.d.ts +7433 -823
@@ -0,0 +1,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,\n Input,\n OnInit,\n OnChanges,\n SimpleChanges,\n TemplateRef,\n ContentChildren,\n 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 styles: [\n `\n :host {\n display: block;\n width: 100%;\n height: 100%;\n }\n table {\n width: 100%;\n border-collapse: collapse;\n display: table;\n }\n thead {\n display: table-header-group;\n }\n tbody {\n display: table-row-group;\n }\n tr {\n display: table-row;\n }\n th,\n td {\n display: table-cell;\n }\n `,\n ],\n imports: [\n CommonModule,\n FormsModule,\n PaginationComponent,\n InputComponent,\n PopoverComponent,\n PopoverContentComponent,\n CheckboxComponent,\n ],\n template: `\n <div class=\"flex h-full w-full flex-col\">\n <div class=\"flex flex-none items-center justify-between py-2\">\n @if (searchable) {\n <div 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\n @if (allowColumnHiding && showSettings) {\n <tolle-popover class=\"ml-auto\">\n <button\n trigger\n [class]=\"\n cn(\n 'ml-auto flex items-center gap-2 rounded-md border border-input bg-background px-3 outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:ring-2 focus-visible:ring-ring',\n size === 'xs' ? 'h-7 text-xs' : 'h-9 text-sm'\n )\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 border-border bg-background p-0\">\n <div class=\"border-b border-border p-2\">\n <p class=\"px-2 text-xs font-medium text-muted-foreground\">Toggle Columns</p>\n </div>\n <div class=\"max-h-[300px] space-y-1 overflow-y-auto p-2\">\n @for (col of columns; track col) {\n <label\n class=\"flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 text-sm transition-colors hover:bg-accent\">\n <tolle-checkbox\n [ngModel]=\"columnVisibility[col.key]\"\n (ngModelChange)=\"toggleColumn(col.key)\" />\n <span>{{ col.label }}</span>\n </label>\n }\n </div>\n </tolle-popover-content>\n </tolle-popover>\n }\n </div>\n\n <div\n [class]=\"\n cn(\n 'relative min-h-0 w-full flex-1 overflow-hidden',\n bordered ? 'rounded-md border border-border' : '',\n tableBackground\n )\n \">\n <div class=\"h-full w-full overflow-auto\" [style.max-height]=\"maxHeight\">\n <table class=\"w-full table-auto border-collapse\">\n <thead [class]=\"cn('border-b border-border', resolvedHeaderBackground)\">\n <tr>\n @if (expandable) {\n <th\n scope=\"col\"\n [class]=\"\n cn(\n 'px-4',\n size === 'xs' ? 'w-[32px]' : 'w-[48px]',\n stickyHeader ? 'sticky top-0 z-10 ' + resolvedHeaderBackground : ''\n )\n \"></th>\n }\n\n @for (col of activeColumns; track col) {\n <th\n scope=\"col\"\n [attr.aria-sort]=\"\n sortKey === col.key\n ? sortDir === 'asc'\n ? 'ascending'\n : sortDir === 'desc'\n ? 'descending'\n : 'none'\n : 'none'\n \"\n [class]=\"\n cn(\n 'text-left font-medium text-foreground',\n headerPaddingClass,\n fontSizeClass,\n col.class,\n stickyHeader\n ? 'sticky top-0 z-10 shadow-sm ' + resolvedHeaderBackground\n : ''\n )\n \">\n @if (col.sortable) {\n <button\n type=\"button\"\n (click)=\"toggleSort(col.key)\"\n class=\"flex cursor-pointer select-none items-center gap-1 rounded-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n {{ col.label }}\n <i [class]=\"getSortIcon(col.key)\" aria-hidden=\"true\"></i>\n </button>\n } @else {\n {{ col.label }}\n }\n </th>\n }\n </tr>\n </thead>\n\n <tbody class=\"divide-y divide-border\">\n @for (row of pagedData; track row; let i = $index) {\n <tr\n [class]=\"cn('transition-colors', getRowBackground(row, i), 'hover:bg-muted/50')\">\n @if (expandable) {\n <td class=\"px-4\">\n <button\n (click)=\"toggleRow(i)\"\n [attr.aria-label]=\"expandedRows.has(i) ? 'Collapse row' : 'Expand row'\"\n [attr.aria-expanded]=\"expandedRows.has(i)\"\n [class]=\"\n cn(\n 'rounded-md transition-colors hover:bg-accent 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 \">\n <i\n [class]=\"\n expandedRows.has(i) ? 'ri-arrow-down-s-line' : 'ri-arrow-right-s-line'\n \"\n aria-hidden=\"true\">\n </i>\n </button>\n </td>\n }\n @for (col of activeColumns; track col) {\n <td [class]=\"cn('align-middle', cellPaddingClass, fontSizeClass, col.class)\">\n @if (getTemplate(col.key); as cell) {\n <ng-container\n *ngTemplateOutlet=\"\n cell.template;\n context: { $implicit: row[col.key], row: row }\n \">\n </ng-container>\n } @else {\n {{ row[col.key] }}\n }\n </td>\n }\n </tr>\n @if (expandedRows.has(i)) {\n <tr class=\"bg-muted/10\">\n <td [attr.colspan]=\"activeColumns.length + (expandable ? 1 : 0)\" class=\"p-0\">\n <div class=\"border-t border-dashed border-border p-6\">\n @if (expandedTemplate) {\n <ng-container *ngTemplateOutlet=\"expandedTemplate; context: { row: row }\">\n </ng-container>\n } @else {\n <p class=\"text-xs italic text-muted-foreground\">No details available.</p>\n }\n </div>\n </td>\n </tr>\n }\n }\n\n @if (pagedData.length === 0) {\n <tr>\n <td\n [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 }\n </tbody>\n </table>\n </div>\n </div>\n\n <div class=\"flex-none\">\n @if (paginate && filteredData.length > 0) {\n <tolle-pagination\n [totalRecords]=\"filteredData.length\"\n [pageSizeOptions]=\"pageSizeOptions\"\n [currentPage]=\"currentPage\"\n [currentPageSize]=\"pageSize\"\n (onPageNumberChange)=\"handlePageChange($event)\"\n (onPageSizeChange)=\"handlePageSizeChange($event)\"></tolle-pagination>\n }\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 // Draws a physical border around the table itself (not the whole component).\n @Input() bordered = true;\n\n // --- Background Colors ---\n // All of these take Tailwind class strings (e.g. 'bg-card', 'bg-muted/40') so\n // they stay theme-token aware and follow dark mode, matching `col.class`.\n\n /** Background for the table surface, behind both header and rows. */\n @Input() tableBackground = '';\n\n /**\n * Background for the header row. Must be opaque when `stickyHeader` is on,\n * otherwise rows scroll visibly through the header — hence the default\n * `bg-background` rather than transparent.\n */\n @Input() headerBackground = 'bg-background';\n\n /** Alternating row backgrounds. Applied to odd rows only. */\n @Input() striped = false;\n\n /** Background applied to odd rows when `striped` is on. */\n @Input() stripeBackground = 'bg-muted/30';\n\n /**\n * Per-row background, resolved against the row's own data — e.g.\n * `[rowBackground]=\"r => r.overdue ? 'bg-destructive/10' : ''\"`.\n * Takes precedence over `striped`.\n */\n @Input() rowBackground?: (row: any, index: number) => string;\n\n /**\n * A sticky header with no background lets rows scroll visibly through it, so\n * fall back to an opaque default if a consumer clears `headerBackground`.\n */\n get resolvedHeaderBackground(): string {\n if (this.stickyHeader && !this.headerBackground) return 'bg-background';\n return this.headerBackground;\n }\n\n /** Resolves the background for a body row: per-row callback wins over striping. */\n getRowBackground(row: any, index: number): string {\n const custom = this.rowBackground?.(row, index);\n if (custom) return custom;\n return this.striped && index % 2 === 1 ? this.stripeBackground : '';\n }\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':\n return 'p-1 px-4';\n case 'sm':\n return 'p-2 px-4';\n case 'lg':\n return 'p-6 px-4';\n default:\n return 'p-4';\n }\n }\n\n get headerPaddingClass(): string {\n switch (this.size) {\n case 'xs':\n return 'h-7 px-4';\n case 'sm':\n return 'h-9 px-4';\n case 'lg':\n return 'h-14 px-4';\n default:\n return 'h-12 px-4';\n }\n }\n\n get fontSizeClass(): string {\n switch (this.size) {\n case 'xs':\n return 'text-[11px]';\n case 'sm':\n return 'text-xs';\n case 'lg':\n return 'text-base';\n default:\n 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 = [...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];\n 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'\n ? 'ri-arrow-up-line text-foreground'\n : 'ri-arrow-down-line text-foreground';\n }\n\n toggleRow(index: number) {\n if (this.expandedRows.has(index)) {\n this.expandedRows.delete(index);\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 with size and invalid options.",
6
+ "category": "date-time",
7
+ "registryDependencies": [
8
+ "calendar"
9
+ ],
10
+ "dependencies": [
11
+ "@floating-ui/dom",
12
+ "class-variance-authority",
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,\n Input,\n Output,\n EventEmitter,\n forwardRef,\n ElementRef,\n ViewChild,\n ChangeDetectorRef,\n ChangeDetectionStrategy,\n OnDestroy,\n HostListener,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { format, isValid, startOfDay } from 'date-fns';\nimport { cn } from './utils/cn';\nimport { CalendarComponent, CalendarMode } from './calendar.component';\n\n/**\n * The trigger button — same shape as `tolle-date-time-picker`'s, so the two\n * controls read as siblings: a bordered field that opens a floating panel,\n * not a typeable text input.\n */\nconst datePickerTriggerVariants = cva(\n 'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background text-foreground shadow-sm transition-colors ' +\n 'focus:outline-none focus-visible:border-primary/80 focus-visible:ring-4 focus-visible:ring-ring/30 ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-8 px-2 text-xs',\n sm: 'h-9 px-3 text-sm',\n default: 'h-10 px-3 text-sm',\n lg: 'h-11 px-4 text-base',\n },\n invalid: {\n true: 'border-destructive focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n false: '',\n },\n },\n defaultVariants: { size: 'default', invalid: false },\n }\n);\n\n/** Variant props for the date picker. */\nexport type DatePickerProps = VariantProps<typeof datePickerTriggerVariants>;\n\n/**\n * A button trigger paired with a `tolle-calendar` in a floating panel.\n *\n * The value is a `Date` normalised to the start of its day — a calendar date\n * has no time, and carrying one would make two selections of the same day\n * compare unequal. `mode` switches the whole control between picking a day, a\n * month or a year, taking the calendar's own quick-actions row with it.\n *\n * Implements `ControlValueAccessor`, so it works with `ngModel` and reactive\n * forms. Use `tolle-date-range-picker` for a span and `tolle-date-time-picker`\n * when a time is needed alongside — this trigger is deliberately the same\n * shape as that one, not a typeable masked field.\n */\n@Component({\n selector: 'tolle-date-picker',\n styles: [':host { display: block; }'],\n imports: [FormsModule, CalendarComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => DatePickerComponent),\n multi: true,\n },\n ],\n template: `\n <button\n #trigger\n type=\"button\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\">\n <span [class]=\"cn('truncate', displayValue ? 'text-foreground' : 'text-muted-foreground')\">\n {{ displayValue || placeholder }}\n </span>\n <i class=\"ri-calendar-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n </button>\n\n @if (isOpen) {\n <div\n #popover\n role=\"dialog\"\n [attr.aria-label]=\"'Choose a date'\"\n class=\"fixed left-0 top-0 z-[50]\"\n style=\"visibility: hidden\">\n <div class=\"rounded-md border border-border bg-popover text-popover-foreground shadow-md\">\n <div class=\"p-3\">\n <tolle-calendar\n [bordered]=\"false\"\n [showQuickActions]=\"false\"\n [(ngModel)]=\"value\"\n (ngModelChange)=\"onCalendarChange($event)\"\n [mode]=\"mode\"\n [disablePastDates]=\"disablePastDates\"\n [minDate]=\"minDate\"\n [maxDate]=\"maxDate\"\n [formatMonthFn]=\"formatMonthFn\"\n [formatYearFn]=\"formatYearFn\"></tolle-calendar>\n </div>\n @if (showQuickActions) {\n <div class=\"flex items-center justify-between gap-2 border-t border-border px-3 py-2\">\n @if (showClear) {\n <button type=\"button\" [class]=\"footerButtonClass\" (click)=\"clearFromFooter()\">\n Clear\n </button>\n }\n <button\n type=\"button\"\n [class]=\"\n cn(\n footerButtonClass,\n 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground',\n !showClear && 'ml-auto'\n )\n \"\n (click)=\"done()\">\n Done\n </button>\n </div>\n }\n </div>\n </div>\n }\n `,\n})\nexport class DatePickerComponent implements OnChanges, ControlValueAccessor, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Text shown on the trigger when no date is selected. @default 'Pick a date' */\n @Input() placeholder = 'Pick a date';\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). @default '' */\n @Input() class = '';\n /** Disables every day before today in the calendar. @default false */\n @Input() disablePastDates = false;\n /** Shows the footer's Clear button. @default true */\n @Input() showClear = true;\n /** Shows the panel's Clear / Done footer. @default true */\n @Input() showQuickActions = true;\n /** Earliest selectable date; earlier days are disabled. */\n @Input() minDate?: Date;\n /** Latest selectable date; later days are disabled. */\n @Input() maxDate?: Date;\n /** Whether the control picks a day, a month or a year. @default 'date' */\n @Input() mode: CalendarMode = 'date';\n /** Overrides how a month is labelled in the calendar header. */\n @Input() formatMonthFn?: (date: Date) => string;\n /** Overrides how a year is labelled in the calendar header. */\n @Input() formatYearFn?: (date: Date) => string;\n /** Overrides how the selected date is rendered on the trigger. */\n @Input() displayFormat?: (date: Date, mode: CalendarMode) => string;\n /** Height and text size of the trigger. @default 'default' */\n @Input() size: DatePickerProps['size'] = 'default';\n /** Applies the destructive border and focus ring. @default false */\n @Input() invalid = false;\n /** Accessible name when there is no associated visible label. @default '' */\n @Input() ariaLabel = '';\n\n /** Emitted with the chosen date, or `null` when the value is cleared. */\n @Output() valueChange = new EventEmitter<Date | null>();\n /** Emitted when the calendar panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the calendar panel closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('trigger') trigger!: ElementRef;\n @ViewChild('popover') popover!: ElementRef;\n\n value: Date | null = null;\n isOpen = false;\n cleanupAutoUpdate?: () => void;\n\n /** Footer buttons, styled exactly as the date-time picker's. */\n readonly footerButtonClass = cn(\n 'rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors',\n 'hover:bg-accent hover:text-accent-foreground',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'\n );\n\n /** Footer Clear — empties the value and leaves the panel open, like the reference. */\n clearFromFooter(): void {\n this.value = null;\n this.emit();\n this.cdr.markForCheck();\n }\n\n /** Footer Done — commits what is already selected and closes. */\n done(): void {\n this.close();\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (\n !this.trigger.nativeElement.contains(event.target) &&\n !this.popover?.nativeElement.contains(event.target)\n ) {\n this.close();\n }\n };\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get computedTriggerClass(): string {\n return cn(datePickerTriggerVariants({ size: this.size, invalid: this.invalid }), this.class);\n }\n\n get displayValue(): string {\n return this.value ? this.formatDate(this.value) : '';\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':\n return format(date, 'MMM d, yyyy');\n case 'month':\n return format(date, 'MMMM yyyy');\n case 'year':\n return format(date, 'yyyy');\n default:\n return format(date, 'MMM d, yyyy');\n }\n }\n\n onCalendarChange(date: Date | null) {\n this.value = date;\n this.emit();\n this.close();\n this.cdr.markForCheck();\n }\n\n toggle(): void {\n this.isOpen ? this.close() : this.open();\n }\n\n onTriggerKeyDown(event: KeyboardEvent): void {\n if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key) && !this.isOpen) {\n event.preventDefault();\n this.open();\n }\n }\n\n @HostListener('keydown.escape') onEscape() {\n if (this.isOpen) this.close();\n }\n\n open() {\n if (this.disabled || this.isOpen) return;\n this.isOpen = true;\n this.opened.emit();\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('pointerdown', this._outsideClickHandler, true);\n });\n }\n\n close() {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.closed.emit();\n this.teardown();\n // The outside-click handler is a raw document listener, so nothing else\n // marks this OnPush view dirty when it fires.\n this.cdr.markForCheck();\n }\n\n /** Reports the current value to the form and to `valueChange`. */\n private emit() {\n this.onChange(this.value);\n this.valueChange.emit(this.value);\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: [offset(4), flip(), shift({ padding: 8 })],\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 private teardown() {\n if (this.cleanupAutoUpdate) {\n this.cleanupAutoUpdate();\n this.cleanupAutoUpdate = undefined;\n }\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n }\n\n ngOnDestroy() {\n this.teardown();\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 this.value = isValid(date) ? startOfDay(date) : null;\n } else {\n this.value = null;\n }\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n 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 with size and invalid options.",
6
+ "category": "date-time",
7
+ "registryDependencies": [
8
+ "range-calendar"
9
+ ],
10
+ "dependencies": [
11
+ "@floating-ui/dom",
12
+ "class-variance-authority",
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,\n Input,\n Output,\n EventEmitter,\n forwardRef,\n ElementRef,\n ViewChild,\n ChangeDetectorRef,\n ChangeDetectionStrategy,\n OnDestroy,\n HostListener,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport {\n computePosition,\n flip,\n shift,\n offset,\n autoUpdate,\n size as sizeMiddleware,\n} from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport { RangeCalendarComponent } from './range-calendar.component';\nimport { format } from 'date-fns';\nimport { DateRange } from './types/date-range';\n\n/**\n * The trigger button — same shape as `tolle-date-time-picker`'s and\n * `tolle-date-picker`'s, so all three read as one family: a bordered field\n * that opens a floating panel, not a typeable text input.\n */\nconst dateRangePickerTriggerVariants = cva(\n 'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background text-foreground shadow-sm transition-colors ' +\n 'focus:outline-none focus-visible:border-primary/80 focus-visible:ring-4 focus-visible:ring-ring/30 ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-8 px-2 text-xs',\n sm: 'h-9 px-3 text-sm',\n default: 'h-10 px-3 text-sm',\n lg: 'h-11 px-4 text-base',\n },\n invalid: {\n true: 'border-destructive focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n false: '',\n },\n },\n defaultVariants: { size: 'default', invalid: false },\n }\n);\n\n/** Variant props for the date range picker. */\nexport type DateRangePickerProps = VariantProps<typeof dateRangePickerTriggerVariants>;\n\n/**\n * A button trigger paired with a `tolle-range-calendar` in a floating panel,\n * for picking a start and an end date.\n *\n * The value is a `DateRange` — `{ start, end }` — and a half-made range is a\n * legal intermediate state: picking a start emits `{ start, end: null }` and\n * leaves the panel open, so a consumer can react to the first click. The panel\n * only closes once both ends exist.\n *\n * Implements `ControlValueAccessor`, so it works with `ngModel` and reactive\n * forms. Use `tolle-date-picker` for a single date — this trigger is\n * deliberately the same shape as that one and as `tolle-date-time-picker`.\n */\n@Component({\n selector: 'tolle-date-range-picker',\n styles: [':host { display: block; }'],\n imports: [FormsModule, RangeCalendarComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => DateRangePickerComponent),\n multi: true,\n },\n ],\n template: `\n <button\n #trigger\n type=\"button\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\">\n <span [class]=\"cn('truncate', displayValue ? 'text-foreground' : 'text-muted-foreground')\">\n {{ displayValue || placeholder }}\n </span>\n <i class=\"ri-calendar-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n </button>\n\n @if (isOpen) {\n <div\n #popover\n role=\"dialog\"\n [attr.aria-label]=\"'Choose a date range'\"\n class=\"fixed left-0 top-0 z-50\"\n style=\"visibility: hidden\">\n <div class=\"rounded-md border border-border bg-popover text-popover-foreground shadow-md\">\n <div class=\"p-3\">\n <tolle-range-calendar\n [bordered]=\"false\"\n [ngModel]=\"value\"\n (rangeSelect)=\"onCalendarSelect($event)\"\n [disablePastDates]=\"disablePastDates\"\n [numberOfMonths]=\"numberOfMonths\"></tolle-range-calendar>\n </div>\n <div class=\"flex items-center justify-between gap-2 border-t border-border px-3 py-2\">\n <button type=\"button\" [class]=\"footerButtonClass\" (click)=\"clearFromFooter()\">\n Clear\n </button>\n <button\n type=\"button\"\n [class]=\"\n cn(\n footerButtonClass,\n 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'\n )\n \"\n (click)=\"done()\">\n Done\n </button>\n </div>\n </div>\n </div>\n }\n `,\n})\nexport class DateRangePickerComponent implements OnChanges, ControlValueAccessor, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Text shown on the trigger when no range is selected. @default 'Pick a date range' */\n @Input() placeholder = 'Pick a date range';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). @default '' */\n @Input() class = '';\n /** Disables every day before today in the calendar. @default false */\n @Input() disablePastDates = false;\n /** Height and text size of the trigger. @default 'default' */\n @Input() size: DateRangePickerProps['size'] = 'default';\n /** Consecutive months shown in the popover (e.g. `2` for a two-month picker). @default 1 */\n @Input() numberOfMonths = 1;\n /** Applies the destructive border and focus ring. @default false */\n @Input() invalid = false;\n /** Accessible name when there is no associated visible label. @default '' */\n @Input() ariaLabel = '';\n\n /** Emitted with the selected range, including the half-made `{ start, end: null }` state. */\n @Output() valueChange = new EventEmitter<DateRange>();\n /** Emitted when the calendar panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the calendar panel closes. */\n @Output() closed = new EventEmitter<void>();\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 /** Footer buttons, styled exactly as the date-time picker's. */\n readonly footerButtonClass = cn(\n 'rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors',\n 'hover:bg-accent hover:text-accent-foreground',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'\n );\n\n /** Footer Clear — empties the range and leaves the panel open, like the reference. */\n clearFromFooter(): void {\n this.value = { start: null, end: null };\n this.emit();\n this.cdr.markForCheck();\n }\n\n /** Footer Done — commits what is already selected and closes. */\n done(): void {\n this.close();\n }\n\n private _outsideClickHandler = (event: MouseEvent) => {\n if (\n !this.trigger.nativeElement.contains(event.target) &&\n !this.popover?.nativeElement.contains(event.target)\n ) {\n this.close();\n }\n };\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get computedTriggerClass(): string {\n return cn(\n dateRangePickerTriggerVariants({ size: this.size, invalid: this.invalid }),\n this.class\n );\n }\n\n get displayValue(): string {\n if (!this.value.start) return '';\n\n const startStr = format(this.value.start, 'MMM d, yyyy');\n if (!this.value.end) return startStr;\n\n const endStr = format(this.value.end, 'MMM d, yyyy');\n return startStr + ' - ' + endStr;\n }\n\n onCalendarSelect(range: DateRange) {\n this.value = range;\n this.emit();\n\n // Close only if range is complete\n if (range.start && range.end) {\n setTimeout(() => this.close(), 150);\n }\n this.cdr.markForCheck();\n }\n\n toggle(): void {\n this.isOpen ? this.close() : this.open();\n }\n\n onTriggerKeyDown(event: KeyboardEvent): void {\n if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key) && !this.isOpen) {\n event.preventDefault();\n this.open();\n }\n }\n\n @HostListener('keydown.escape') onEscape() {\n if (this.isOpen) this.close();\n }\n\n open() {\n if (this.disabled || this.isOpen) return;\n this.isOpen = true;\n this.opened.emit();\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 =\n typeof requestAnimationFrame !== 'undefined'\n ? requestAnimationFrame\n : (fn: FrameRequestCallback) => setTimeout(fn, 0);\n schedule(() => {\n this.updatePosition();\n document.addEventListener('pointerdown', this._outsideClickHandler, true);\n });\n }\n\n close() {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.closed.emit();\n this.teardown();\n // Both the outside-click handler and the deferred close after a completed\n // range run outside any template binding, so nothing else marks this OnPush\n // view dirty.\n this.cdr.markForCheck();\n }\n\n /** Reports the current range to the form and to `valueChange`. */\n private emit() {\n this.onChange(this.value);\n this.valueChange.emit(this.value);\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-start',\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 sizeMiddleware({\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 private teardown() {\n if (this.cleanupAutoUpdate) {\n this.cleanupAutoUpdate();\n this.cleanupAutoUpdate = undefined;\n }\n document.removeEventListener('pointerdown', this._outsideClickHandler, true);\n }\n\n ngOnDestroy() {\n this.teardown();\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 {\n this.onChange = fn;\n }\n registerOnTouched(fn: any): void {\n this.onTouched = fn;\n }\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n 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,28 @@
1
+ {
2
+ "name": "date-time-picker",
3
+ "type": "registry:ui",
4
+ "title": "Date Time Picker",
5
+ "description": "Date Time Picker component with size and invalid options.",
6
+ "category": "date-time",
7
+ "registryDependencies": [
8
+ "calendar",
9
+ "time-picker"
10
+ ],
11
+ "dependencies": [
12
+ "@floating-ui/dom",
13
+ "class-variance-authority",
14
+ "date-fns"
15
+ ],
16
+ "files": [
17
+ {
18
+ "path": "date-time-picker.component.ts",
19
+ "target": "ui/date-time-picker.component.ts",
20
+ "content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n forwardRef,\n ElementRef,\n ViewChild,\n ChangeDetectorRef,\n ChangeDetectionStrategy,\n OnChanges,\n OnDestroy,\n inject,\n} from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { format, isSameDay, isValid, startOfDay } from 'date-fns';\nimport { cn } from './utils/cn';\nimport { CalendarComponent } from './calendar.component';\nimport { TimeColumnsComponent, TimeParts, to12Hour } from './time-picker.component';\n\nconst SECONDS_PER_DAY = 24 * 3600;\n\nconst dateTimePickerTriggerVariants = cva(\n 'flex w-full items-center justify-between gap-2 rounded-md border border-input bg-background text-foreground shadow-sm transition-colors ' +\n 'focus:outline-none focus-visible:border-primary/80 focus-visible:ring-4 focus-visible:ring-ring/30 ' +\n 'disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-8 px-2 text-xs',\n sm: 'h-9 px-3 text-sm',\n default: 'h-10 px-3 text-sm',\n lg: 'h-11 px-4 text-base',\n },\n invalid: {\n true: 'border-destructive focus-visible:border-destructive/80 focus-visible:ring-destructive/30',\n false: '',\n },\n },\n defaultVariants: { size: 'default', invalid: false },\n }\n);\n\n/** Variant props for the date-time picker trigger. */\nexport type DateTimePickerProps = VariantProps<typeof dateTimePickerTriggerVariants>;\n\n/**\n * One control for a date *and* a time: `tolle-calendar` on the left, the\n * `tolle-time-columns` strip on the right, in a single floating panel with a\n * Clear / Done footer.\n *\n * The value is a `Date`, because a date-time genuinely is a moment. Changing\n * the date preserves the chosen time and changing the time preserves the\n * chosen date — the two halves are only ever recombined, never reset.\n *\n * Implements `ControlValueAccessor`, so it works with `ngModel` and reactive\n * forms. Use `tolle-date-picker` for a date alone and `tolle-time-picker` for a\n * time alone.\n * @new\n */\n@Component({\n selector: 'tolle-date-time-picker',\n styles: [':host { display: block; }'],\n imports: [FormsModule, CalendarComponent, TimeColumnsComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => DateTimePickerComponent),\n multi: true,\n },\n ],\n template: `\n <button\n #trigger\n type=\"button\"\n aria-haspopup=\"dialog\"\n [attr.aria-expanded]=\"isOpen\"\n [attr.aria-controls]=\"isOpen ? panelId : null\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n [disabled]=\"disabled\"\n [class]=\"computedTriggerClass\"\n (click)=\"toggle()\"\n (keydown)=\"onTriggerKeyDown($event)\">\n <span [class]=\"cn('truncate', displayValue ? 'text-foreground' : 'text-muted-foreground')\">\n {{ displayValue || placeholder }}\n </span>\n <i class=\"ri-calendar-schedule-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n </button>\n\n @if (isOpen) {\n <div\n #panel\n role=\"dialog\"\n [id]=\"panelId\"\n [attr.aria-label]=\"ariaLabel || 'Choose a date and time'\"\n class=\"fixed left-0 top-0 z-[9999]\"\n style=\"visibility: hidden\">\n <div class=\"rounded-md border border-border bg-popover text-popover-foreground shadow-md\">\n <div class=\"flex flex-col sm:flex-row\">\n <div class=\"p-3\">\n <tolle-calendar\n [bordered]=\"false\"\n [showQuickActions]=\"false\"\n [minDate]=\"calendarMin\"\n [maxDate]=\"calendarMax\"\n [ngModel]=\"value\"\n (dateSelect)=\"onDateSelect($event)\"></tolle-calendar>\n </div>\n <div class=\"border-t border-border p-3 sm:border-l sm:border-t-0\">\n <tolle-time-columns\n #columns\n [hour]=\"hour\"\n [minute]=\"minute\"\n [second]=\"second\"\n [use12Hours]=\"use12Hours\"\n [showSeconds]=\"showSeconds\"\n [minuteStep]=\"minuteStep\"\n [minSeconds]=\"minSeconds\"\n [maxSeconds]=\"maxSeconds\"\n [size]=\"size\"\n (timeChange)=\"onTimeChange($event)\"></tolle-time-columns>\n </div>\n </div>\n <div class=\"flex items-center justify-between gap-2 border-t border-border px-3 py-2\">\n <button type=\"button\" [class]=\"footerButtonClass\" (click)=\"clear()\">Clear</button>\n <button\n type=\"button\"\n [class]=\"\n cn(\n footerButtonClass,\n 'bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground'\n )\n \"\n (click)=\"done()\">\n Done\n </button>\n </div>\n </div>\n </div>\n }\n `,\n})\nexport class DateTimePickerComponent implements ControlValueAccessor, OnChanges, OnDestroy {\n /** Text shown on the trigger when nothing is selected. @default 'Select date and time' */\n @Input() placeholder = 'Select date and time';\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Renders a 12-hour face with an AM/PM column. @default false */\n @Input() use12Hours = false;\n /** Adds a seconds column. @default false */\n @Input() showSeconds = false;\n /** Interval between minute entries, in minutes. @default 1 */\n @Input() minuteStep = 1;\n /** Earliest selectable moment; earlier days and times are disabled. */\n @Input() min?: Date;\n /** Latest selectable moment; later days and times are disabled. */\n @Input() max?: Date;\n /** date-fns pattern used to render the value on the trigger. @default 'MMM d, yyyy h:mm a' */\n @Input() displayFormat = 'MMM d, yyyy h:mm a';\n /** Height and text size of the trigger and entries. @default 'default' */\n @Input() size: DateTimePickerProps['size'] = 'default';\n /** Applies the destructive border and focus ring. @default false */\n @Input() invalid = false;\n /** Accessible name when there is no associated visible label. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the trigger via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the chosen moment, or `null` when the value is cleared. */\n @Output() valueChange = new EventEmitter<Date | null>();\n /** Emitted when the panel opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the panel closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('trigger') triggerEl?: ElementRef<HTMLElement>;\n @ViewChild('panel') panelEl?: ElementRef<HTMLElement>;\n @ViewChild('columns') columnsCmp?: TimeColumnsComponent;\n\n readonly panelId = 'date-time-picker-panel-' + Math.random().toString(36).slice(2, 11);\n\n value: Date | null = null;\n isOpen = false;\n\n /**\n * Day-only copies of `min` / `max` for the calendar. The calendar compares\n * whole `Date`s, so passing a bound that carries a time would disable its own\n * day — 09:00 on the min day would look \"before\" a 09:30 minimum.\n */\n calendarMin?: Date;\n calendarMax?: Date;\n\n readonly footerButtonClass = cn(\n 'rounded-md px-3 py-1.5 text-sm font-medium text-muted-foreground transition-colors',\n 'hover:bg-accent hover:text-accent-foreground',\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring'\n );\n\n protected cn = cn;\n\n private cleanup?: () => void;\n private onChange: (value: Date | null) => void = () => {};\n private onTouched: () => void = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n ngOnChanges(): void {\n this.calendarMin = this.min ? startOfDay(this.min) : undefined;\n this.calendarMax = this.max ? startOfDay(this.max) : undefined;\n\n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n private readonly outsideClickHandler = (event: MouseEvent) => {\n const target = event.target as Node;\n if (\n !this.triggerEl?.nativeElement.contains(target) &&\n !this.panelEl?.nativeElement.contains(target)\n ) {\n this.close();\n }\n };\n\n private readonly escapeHandler = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && this.isOpen) {\n event.preventDefault();\n this.close();\n this.triggerEl?.nativeElement.focus();\n }\n };\n\n /* ---------------- derived ---------------- */\n\n get hour(): number | null {\n return this.value ? this.value.getHours() : null;\n }\n\n get minute(): number | null {\n return this.value ? this.value.getMinutes() : null;\n }\n\n get second(): number | null {\n return this.value ? this.value.getSeconds() : null;\n }\n\n get displayValue(): string {\n if (!this.value) return '';\n try {\n return format(this.value, this.displayFormat);\n } catch {\n // A bad pattern should not blank the control; fall back to something readable.\n const { hour12, meridiem } = to12Hour(this.value.getHours());\n return (\n format(this.value, 'MMM d, yyyy') +\n ' ' +\n hour12 +\n ':' +\n String(this.value.getMinutes()).padStart(2, '0') +\n ' ' +\n meridiem\n );\n }\n }\n\n /**\n * The time bound only bites on the boundary day itself: on the min day the\n * columns start at the min time, on any later day they start at midnight.\n */\n get minSeconds(): number {\n if (!this.min) return 0;\n const day = this.value ?? this.min;\n if (!isSameDay(day, this.min)) return 0;\n return this.min.getHours() * 3600 + this.min.getMinutes() * 60 + this.min.getSeconds();\n }\n\n get maxSeconds(): number {\n if (!this.max) return SECONDS_PER_DAY - 1;\n const day = this.value ?? this.max;\n if (!isSameDay(day, this.max)) return SECONDS_PER_DAY - 1;\n return this.max.getHours() * 3600 + this.max.getMinutes() * 60 + this.max.getSeconds();\n }\n\n get computedTriggerClass(): string {\n return cn(\n dateTimePickerTriggerVariants({ size: this.size, invalid: this.invalid }),\n this.class\n );\n }\n\n /* ---------------- open / close ---------------- */\n\n toggle(): void {\n this.isOpen ? this.close() : this.open();\n }\n\n open(): void {\n if (this.disabled || this.isOpen) return;\n this.isOpen = true;\n this.opened.emit();\n this.cdr.markForCheck();\n\n setTimeout(() => {\n this.updatePosition();\n this.columnsCmp?.scrollSelectedIntoView();\n document.addEventListener('pointerdown', this.outsideClickHandler, true);\n document.addEventListener('keydown', this.escapeHandler, true);\n });\n }\n\n close(): void {\n if (!this.isOpen) return;\n this.isOpen = false;\n this.closed.emit();\n this.onTouched();\n this.teardown();\n this.cdr.markForCheck();\n }\n\n onTriggerKeyDown(event: KeyboardEvent): void {\n if (['ArrowDown', 'ArrowUp', 'Enter', ' '].includes(event.key) && !this.isOpen) {\n event.preventDefault();\n this.open();\n }\n }\n\n /* ---------------- value ---------------- */\n\n /**\n * Applies a newly picked day, carrying the existing time across. With no\n * time chosen yet the day starts at midnight.\n */\n onDateSelect(date: Date | null): void {\n if (!date) {\n this.clear();\n return;\n }\n\n const previous = this.value;\n this.commit(\n new Date(\n date.getFullYear(),\n date.getMonth(),\n date.getDate(),\n previous ? previous.getHours() : 0,\n previous ? previous.getMinutes() : 0,\n previous ? previous.getSeconds() : 0,\n 0\n )\n );\n }\n\n /**\n * Applies a newly picked time, carrying the existing day across. With no day\n * chosen yet the time lands on today.\n */\n onTimeChange(parts: TimeParts): void {\n const base = this.value ?? new Date();\n this.commit(\n new Date(\n base.getFullYear(),\n base.getMonth(),\n base.getDate(),\n parts.hour,\n parts.minute,\n parts.second,\n 0\n )\n );\n }\n\n clear(): void {\n this.commit(null);\n // The calendar keeps its own selection and ignores a null written through\n // the CVA, so closing is what actually returns it to a clean state — it is\n // rebuilt from scratch the next time the panel opens.\n this.close();\n }\n\n done(): void {\n this.close();\n this.triggerEl?.nativeElement.focus();\n }\n\n private commit(next: Date | null): void {\n this.value = next;\n this.onChange(next);\n this.valueChange.emit(next);\n this.cdr.markForCheck();\n }\n\n private updatePosition(): void {\n const trigger = this.triggerEl?.nativeElement;\n const panel = this.panelEl?.nativeElement;\n if (!trigger || !panel) return;\n\n this.cleanup = autoUpdate(trigger, panel, () => {\n computePosition(trigger, panel, {\n strategy: 'fixed',\n placement: 'bottom-start',\n middleware: [offset(4), flip(), shift({ padding: 8 })],\n }).then(({ x, y }) => {\n Object.assign(panel.style, {\n left: x + 'px',\n top: y + 'px',\n visibility: 'visible',\n });\n });\n });\n }\n\n private teardown(): void {\n this.cleanup?.();\n this.cleanup = undefined;\n document.removeEventListener('pointerdown', this.outsideClickHandler, true);\n document.removeEventListener('keydown', this.escapeHandler, true);\n }\n\n ngOnDestroy(): void {\n this.teardown();\n }\n\n /* ---------------- ControlValueAccessor ---------------- */\n\n writeValue(value: Date | string | null | undefined): void {\n if (value instanceof Date && isValid(value)) {\n this.value = value;\n } else if (typeof value === 'string' && value) {\n const parsed = new Date(value);\n this.value = isValid(parsed) ? parsed : null;\n } else {\n this.value = null;\n }\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: Date | null) => void): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: () => void): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n if (isDisabled) this.close();\n this.cdr.markForCheck();\n }\n}\n"
21
+ },
22
+ {
23
+ "path": "utils/cn.ts",
24
+ "target": "ui/utils/cn.ts",
25
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
26
+ }
27
+ ]
28
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "name": "direction",
3
+ "type": "registry:ui",
4
+ "title": "Direction",
5
+ "description": "Direction component.",
6
+ "category": "utilities",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "direction.component.ts",
12
+ "target": "ui/direction.component.ts",
13
+ "content": "import {\n Directive,\n Injectable,\n Input,\n Output,\n EventEmitter,\n HostBinding,\n OnChanges,\n inject,\n} from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/** Writing direction of a subtree: explicit, or inferred from its text. */\nexport type Direction = 'ltr' | 'rtl' | 'auto';\n\n/**\n * Publishes the application's current writing direction so components can adapt\n * without reading the DOM.\n *\n * Direction is not an input of most components, so an OnPush view would never be\n * marked dirty by a change. Subscribe to `direction$` and call `markForCheck()`\n * on emission wherever the rendered output depends on it.\n */\n@Injectable({ providedIn: 'root' })\nexport class DirectionService {\n private directionSource = new BehaviorSubject<Direction>('ltr');\n\n /** Emits the current writing direction, starting with the present value. */\n readonly direction$ = this.directionSource.asObservable();\n\n /** The current writing direction. */\n get direction(): Direction {\n return this.directionSource.value;\n }\n\n /** Whether the current direction is explicitly right-to-left. */\n get isRtl(): boolean {\n return this.directionSource.value === 'rtl';\n }\n\n /** Sets the current direction, emitting only when it actually changes. */\n setDirection(direction: Direction): void {\n if (this.directionSource.value !== direction) this.directionSource.next(direction);\n }\n}\n\n/**\n * Sets the `dir` attribute on its host and publishes that direction to\n * `DirectionService`, so a whole chat surface can be flipped to RTL from one\n * place. Put it on the outermost element of the subtree you want to flip.\n * @new\n */\n@Directive({\n selector: '[tolleDirection]',\n standalone: true,\n})\nexport class DirectionDirective implements OnChanges {\n /** Writing direction applied to the host's `dir` attribute. @default 'ltr' */\n @Input() tolleDirection: Direction = 'ltr';\n /** Whether to publish this direction to the app-wide `DirectionService`. @default true */\n @Input() tolleDirectionPublish = true;\n\n /** Emitted with the direction whenever it changes. */\n @Output() directionChange = new EventEmitter<Direction>();\n\n private readonly service = inject(DirectionService);\n\n /** The resolved `dir` attribute value written onto the host. */\n @HostBinding('attr.dir')\n get dir(): Direction {\n return this.tolleDirection ?? 'ltr';\n }\n\n ngOnChanges(): void {\n const direction = this.dir;\n if (this.tolleDirectionPublish) this.service.setDirection(direction);\n this.directionChange.emit(direction);\n }\n}\n"
14
+ }
15
+ ]
16
+ }
@@ -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 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: `:host { display: block; }`\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 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: `:host { display: block; }`\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 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: `:host { display: block; }`\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 imports: [],\n template: `<div role=\"separator\" aria-hidden=\"true\" class=\"-mx-1 my-1 h-px bg-border\"></div>`,\n styles: `:host { display: block; }`\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. Capture phase so container components that call\n // stopPropagation() on their root (modal, sheet, country-selector) cannot\n // prevent the event from reaching us.\n setTimeout(() => {\n document.addEventListener('pointerdown', this.outsideClick, true);\n });\n }\n\n private close() {\n this.isOpen = false;\n this.cleanup?.();\n this.menuElement?.remove();\n document.removeEventListener('pointerdown', this.outsideClick, true);\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';\n\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 styles: [':host { display: block; }'],\n imports: [],\n template: `\n <div [class]=\"cn(emptyStateVariants({ variant }), class)\">\n <div\n [class]=\"\n cn(\n 'flex items-center justify-center rounded-full bg-muted',\n variant === 'minimal' ? 'h-12 w-12' : 'h-20 w-20'\n )\n \">\n <ng-content select=\"[icon]\">\n <i\n aria-hidden=\"true\"\n [class]=\"\n cn(\n 'ri-inbox-line text-muted-foreground/60',\n variant === 'minimal' ? 'text-xl' : 'text-3xl'\n )\n \"></i>\n </ng-content>\n </div>\n\n <h3\n [class]=\"\n cn(\n 'font-semibold text-foreground',\n variant === 'minimal' ? 'mt-2 text-sm' : 'mt-4 text-lg'\n )\n \">\n {{ title }}\n </h3>\n\n @if (description) {\n <p\n [class]=\"\n cn(\n 'text-muted-foreground',\n variant === 'minimal' ? 'mt-1 text-xs' : 'mb-6 mt-2 max-w-sm text-sm'\n )\n \">\n {{ description }}\n </p>\n }\n\n @if (variant !== 'minimal') {\n <div class=\"flex items-center justify-center gap-3\">\n <ng-content select=\"[actions]\"></ng-content>\n </div>\n }\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": "field",
3
+ "type": "registry:ui",
4
+ "title": "Field",
5
+ "description": "Field component with orientation options.",
6
+ "category": "forms",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "field.component.ts",
14
+ "target": "ui/field.component.ts",
15
+ "content": "import {\n Component,\n Input,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnChanges,\n inject,\n} from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst fieldVariants = cva('group/field flex w-full gap-2 data-[invalid=true]:text-destructive', {\n variants: {\n orientation: {\n vertical: 'flex-col',\n horizontal: 'flex-row items-center justify-between',\n // shadcn uses container queries here, but this preset is Tailwind 3.4 with no\n // container-queries plugin — `@md/...` classes would be silently dropped and the\n // field would never go horizontal. Viewport breakpoints work with the stack we have.\n responsive: 'flex-col md:flex-row md:items-center md:justify-between',\n },\n },\n defaultVariants: { orientation: 'vertical' },\n});\n\nexport type FieldProps = VariantProps<typeof fieldVariants>;\n\n/**\n * Wraps a label, a control and its help/error text into one accessible form\n * field. Composes *around* the existing controls (`tolle-input`,\n * `tolle-select`, …) — it does not replace them.\n *\n * Use `tolle-item` instead when displaying content rather than a form control.\n * @new\n */\n@Component({\n selector: 'tolle-field',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div role=\"group\" [attr.data-invalid]=\"invalid || null\" [class]=\"computedClass\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class FieldComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Layout direction; `responsive` goes horizontal at the `@md` container width. @default 'vertical' */\n @Input() orientation: FieldProps['orientation'] = 'vertical';\n /** Marks the field invalid, turning on the destructive styling for descendants. @default false */\n @Input() invalid = false;\n /** Extra Tailwind classes merged onto the field via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(fieldVariants({ orientation: this.orientation }), this.class);\n }\n}\n\n/**\n * Label for a field's control. Set `for` to the control's `id` so clicking the\n * label focuses it.\n */\n@Component({\n selector: 'tolle-field-label',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <label\n [attr.for]=\"for || null\"\n [class]=\"\n cn(\n 'flex items-center gap-2 text-sm font-medium leading-none text-foreground',\n 'group-data-[invalid=true]/field:text-destructive',\n 'peer-disabled:cursor-not-allowed peer-disabled:opacity-50',\n class\n )\n \">\n <ng-content></ng-content>\n @if (required) {\n <span class=\"text-destructive\" aria-hidden=\"true\">*</span>\n }\n </label>\n `,\n})\nexport class FieldLabelComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Id of the control this label describes; wire it to the control's `id`. @default '' */\n @Input() for = '';\n /** Appends a required marker (*) after the label text. @default false */\n @Input() required = false;\n /** Extra Tailwind classes merged onto the label via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Column that groups the control with its description and error text. */\n@Component({\n selector: 'tolle-field-content',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('flex flex-1 flex-col gap-1.5', class)\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class FieldContentComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the content column via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/**\n * Helper text below a control. Give it an `id` and point the control's\n * `aria-describedby` at it.\n */\n@Component({\n selector: 'tolle-field-description',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <p [id]=\"id || null\" [class]=\"cn('text-balance text-xs text-muted-foreground', class)\">\n <ng-content></ng-content>\n </p>\n `,\n})\nexport class FieldDescriptionComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Id to reference from the control's `aria-describedby`. @default '' */\n @Input() id = '';\n /** Extra Tailwind classes merged onto the description via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/**\n * Error text for a field. Renders nothing when there is no error, so it is safe\n * to leave in the template permanently. Announces via `role=\"alert\"`.\n */\n@Component({\n selector: 'tolle-field-error',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (messages.length) {\n <div\n [id]=\"id || null\"\n role=\"alert\"\n [class]=\"cn('text-xs font-medium text-destructive', class)\">\n @if (messages.length === 1) {\n {{ messages[0] }}\n }\n @if (messages.length > 1) {\n <ul class=\"ml-4 list-disc space-y-0.5\">\n @for (message of messages; track message) {\n <li>{{ message }}</li>\n }\n </ul>\n }\n </div>\n }\n `,\n})\nexport class FieldErrorComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /**\n * Validation message(s). Accepts a single string, an array of strings, or an\n * Angular `ValidationErrors`-style object whose values are messages.\n * @default ''\n */\n @Input() errors: string | string[] | Record<string, any> | null = '';\n /** Id to reference from the control's `aria-describedby`. @default '' */\n @Input() id = '';\n /** Extra Tailwind classes merged onto the error text via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n\n /** Normalises the three accepted `errors` shapes to a flat list of strings. */\n get messages(): string[] {\n const e = this.errors;\n if (!e) return [];\n if (typeof e === 'string') return e ? [e] : [];\n if (Array.isArray(e)) return e.filter(Boolean).map(String);\n return Object.values(e)\n .filter(v => v !== false && v !== null && v !== undefined)\n .map(v => (typeof v === 'string' ? v : String(v)));\n }\n}\n\n/** Stacks several fields with consistent spacing. */\n@Component({\n selector: 'tolle-field-group',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('flex w-full flex-col gap-6', class)\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class FieldGroupComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the group via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Semantic `<fieldset>` grouping related fields under a legend. */\n@Component({\n selector: 'tolle-field-set',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<fieldset [class]=\"cn('flex w-full flex-col gap-4 border-0 p-0', class)\">\n <ng-content></ng-content>\n </fieldset>`,\n})\nexport class FieldSetComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the fieldset via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\nconst fieldLegendVariants = cva('mb-1 font-medium text-foreground', {\n variants: {\n variant: {\n legend: 'text-base',\n label: 'text-sm leading-none',\n },\n },\n defaultVariants: { variant: 'legend' },\n});\n\nexport type FieldLegendProps = VariantProps<typeof fieldLegendVariants>;\n\n/** `<legend>` for a `tolle-field-set`. */\n@Component({\n selector: 'tolle-field-legend',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<legend [class]=\"computedClass\"><ng-content></ng-content></legend>`,\n})\nexport class FieldLegendComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Type scale of the legend. @default 'legend' */\n @Input() variant: FieldLegendProps['variant'] = 'legend';\n /** Extra Tailwind classes merged onto the legend via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(fieldLegendVariants({ variant: this.variant }), this.class);\n }\n}\n\n/** Divider between sections of a form. */\n@Component({\n selector: 'tolle-field-separator',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div role=\"separator\" [class]=\"cn('h-px w-full bg-border', class)\"></div>`,\n})\nexport class FieldSeparatorComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the separator via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/** Title for a group of fields that is not a `<legend>`. */\n@Component({\n selector: 'tolle-field-title',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div [class]=\"cn('text-sm font-medium leading-none text-foreground', class)\">\n <ng-content></ng-content>\n </div>`,\n})\nexport class FieldTitleComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Extra Tailwind classes merged onto the title via `cn()` (last-wins). */\n @Input() class = '';\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 {\n Component,\n Input,\n Output,\n EventEmitter,\n inject,\n TemplateRef,\n ViewChild,\n ViewContainerRef,\n OnDestroy,\n ContentChild,\n forwardRef,\n ElementRef,\n AfterViewInit,\n} from '@angular/core';\n\nimport { computePosition, flip, shift, offset, autoUpdate } from '@floating-ui/dom';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-hover-card',\n styles: [':host { display: block; }'],\n imports: [],\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))\n 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 imports: [],\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 styles: [':host { display: block; }'],\n imports: [],\n template: `\n @if (isVisible) {\n <div\n #content\n [class]=\"computedClass\"\n (mouseenter)=\"onMouseEnter()\"\n (mouseleave)=\"onMouseLeave()\">\n <ng-content></ng-content>\n </div>\n }\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,26 @@
1
+ {
2
+ "name": "inline-citation",
3
+ "type": "registry:ui",
4
+ "title": "Inline Citation",
5
+ "description": "Inline Citation component with variant and size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [
8
+ "hover-card"
9
+ ],
10
+ "dependencies": [
11
+ "@floating-ui/dom",
12
+ "class-variance-authority"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "inline-citation.component.ts",
17
+ "target": "ui/inline-citation.component.ts",
18
+ "content": "import {\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n Component,\n Input,\n OnDestroy,\n inject,\n OnChanges,\n} from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport {\n HoverCardComponent,\n HoverCardTriggerComponent,\n HoverCardContentComponent,\n} from './hover-card.component';\n\nconst inlineCitationVariants = cva(\n 'cursor-pointer rounded-sm align-super font-medium leading-none transition-colors',\n {\n variants: {\n variant: {\n default: 'text-primary hover:text-primary/80',\n muted: 'text-muted-foreground hover:text-foreground',\n subtle: 'text-foreground/60 hover:text-foreground',\n },\n size: {\n sm: 'text-[9px]',\n default: 'text-[10px]',\n },\n },\n defaultVariants: { variant: 'default', size: 'default' },\n }\n);\n\nexport type InlineCitationProps = VariantProps<typeof inlineCitationVariants>;\n\n/**\n * A superscript citation marker for prose. Hovering the marker reveals the\n * projected source card, positioned by the existing `tolle-hover-card`.\n *\n * ```html\n * The model was released in March.<tolle-inline-citation [index]=\"1\">\n * <tolle-inline-citation-card\n * title=\"Release notes\"\n * url=\"https://example.com/notes\"\n * snippet=\"Shipped on 14 March.\"\n * ></tolle-inline-citation-card>\n * </tolle-inline-citation>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-inline-citation',\n styles: [':host { display: inline-flex; }'],\n imports: [HoverCardComponent, HoverCardTriggerComponent, HoverCardContentComponent],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <tolle-hover-card\n [openDelay]=\"openDelay\"\n [closeDelay]=\"closeDelay\"\n [placement]=\"placement\"\n (mouseenter)=\"scheduleRefresh(openDelay)\"\n (mouseleave)=\"scheduleRefresh(closeDelay)\">\n <tolle-hover-card-trigger>\n <sup\n role=\"button\"\n tabindex=\"0\"\n [attr.aria-label]=\"ariaLabel || 'Citation ' + index\"\n [class]=\"computedClass\"\n >{{ index }}</sup\n >\n </tolle-hover-card-trigger>\n <tolle-hover-card-content [class]=\"contentClass\">\n <ng-content></ng-content>\n </tolle-hover-card-content>\n </tolle-hover-card>\n `,\n})\nexport class InlineCitationComponent implements OnChanges, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Number rendered in the superscript marker. @default 1 */\n @Input() index: number | string = 1;\n /** Colour treatment of the marker. @default 'default' */\n @Input() variant: InlineCitationProps['variant'] = 'default';\n /** Text size of the marker. @default 'default' */\n @Input() size: InlineCitationProps['size'] = 'default';\n /** Side of the marker the source card opens on. @default 'top' */\n @Input() placement: 'top' | 'bottom' | 'left' | 'right' = 'top';\n /** Milliseconds to wait before opening the card. @default 150 */\n @Input() openDelay = 150;\n /** Milliseconds to wait before closing the card. @default 200 */\n @Input() closeDelay = 200;\n /** Accessible label for the marker; falls back to \"Citation {index}\". @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the marker via `cn()` (last-wins). */\n @Input() class = '';\n /** Extra Tailwind classes merged onto the hover card panel via `cn()` (last-wins). */\n @Input() cardClass = '';\n\n private readonly cdr = inject(ChangeDetectorRef);\n private refreshTimer?: ReturnType<typeof setTimeout>;\n\n get computedClass() {\n return cn(inlineCitationVariants({ variant: this.variant, size: this.size }), this.class);\n }\n\n get contentClass() {\n return cn('w-72 p-0', this.cardClass);\n }\n\n /**\n * `tolle-hover-card` flips its own visibility from a `setTimeout`, which never\n * marks this OnPush view dirty. Re-check just after the delay elapses so the\n * card actually appears (and disappears) inside an OnPush host.\n */\n scheduleRefresh(delay: number): void {\n clearTimeout(this.refreshTimer);\n this.refreshTimer = setTimeout(() => this.cdr.markForCheck(), delay + 32);\n }\n\n ngOnDestroy(): void {\n clearTimeout(this.refreshTimer);\n }\n}\n\n/** The source preview shown inside a citation's hover card. */\n@Component({\n selector: 'tolle-inline-citation-card',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <p class=\"truncate text-sm font-medium text-popover-foreground\">{{ title }}</p>\n @if (url) {\n <a\n [href]=\"url\"\n target=\"_blank\"\n rel=\"noreferrer noopener\"\n class=\"mt-0.5 flex items-center gap-1 truncate text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline\">\n <i class=\"ri-external-link-line shrink-0\" aria-hidden=\"true\"></i>\n <span class=\"truncate\">{{ displayUrl }}</span>\n </a>\n }\n @if (snippet) {\n <p class=\"mt-2 line-clamp-3 text-xs leading-relaxed text-muted-foreground\">{{ snippet }}</p>\n }\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class InlineCitationCardComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Title of the cited source. @default '' */\n @Input() title = '';\n /** Link to the cited source. @default '' */\n @Input() url = '';\n /** Short excerpt from the source. @default '' */\n @Input() snippet = '';\n /** Extra Tailwind classes merged onto the card via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn('flex flex-col p-3', this.class);\n }\n\n /** The URL without its scheme, which reads better in a narrow card. */\n get displayUrl(): string {\n return this.url.replace(/^https?:\\/\\//, '');\n }\n}\n\n/** A pulled quote from the cited source, shown inside the citation card. */\n@Component({\n selector: 'tolle-inline-citation-quote',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <blockquote [class]=\"computedClass\">\n <ng-content></ng-content>\n @if (cite) {\n <footer class=\"mt-1 text-[11px] not-italic text-muted-foreground/80\">— {{ cite }}</footer>\n }\n </blockquote>\n `,\n})\nexport class InlineCitationQuoteComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Attribution shown beneath the quote. @default '' */\n @Input() cite = '';\n /** Extra Tailwind classes merged onto the quote via `cn()` (last-wins). */\n @Input() class = '';\n\n get computedClass() {\n return cn(\n 'mt-2 border-l-2 border-border pl-3 text-xs italic leading-relaxed text-muted-foreground',\n this.class\n );\n }\n}\n"
19
+ },
20
+ {
21
+ "path": "utils/cn.ts",
22
+ "target": "ui/utils/cn.ts",
23
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
24
+ }
25
+ ]
26
+ }