@tolle_/tolle-ui 21.1.1 → 21.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
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,28 @@
1
+ {
2
+ "name": "chart",
3
+ "type": "registry:ui",
4
+ "title": "Chart",
5
+ "description": "Chart component with variant and density options.",
6
+ "category": "data",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "chart.component.ts",
14
+ "target": "ui/chart.component.ts",
15
+ "content": "import {\n Component,\n Directive,\n Input,\n Output,\n EventEmitter,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n ElementRef,\n ViewChild,\n OnInit,\n OnChanges,\n OnDestroy,\n AfterViewInit,\n inject,\n} from '@angular/core';\n\nimport { Subscription } from 'rxjs';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport {\n ChartService,\n type ChartSeries,\n type ChartMargin,\n type ChartOrientation,\n} from './chart.service';\n\nexport type {\n ChartSeries,\n ChartMargin,\n ChartTick,\n ChartBand,\n ChartOrientation,\n} from './chart.service';\n\nlet chartId = 0;\n\n/**\n * Base for every chart part that has to redraw when the shared scales change.\n * Parts subscribe to the service rather than reading the container component,\n * so an OnPush child still updates when the container's inputs or width change.\n */\n@Directive()\nexport abstract class ChartChild implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook subclasses keep\n * rendering the class they were born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n protected readonly chart = inject(ChartService);\n protected readonly cdr = inject(ChangeDetectorRef);\n protected readonly subscriptions = new Subscription();\n\n /**\n * Identity for the mark `*ngFor`s. The collections behind them are rebuilt on\n * every read, so without this `*ngFor` sees new identities each pass and\n * replaces every node instead of patching it. Marks are positional, so the\n * index IS the identity.\n */\n\n ngOnInit(): void {\n this.register();\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** Hook for parts that need to influence the shared scales before first paint. */\n protected register(): void {}\n}\n\n/**\n * The floating readout for the hovered x position. Lists every series at that\n * x, so the pointer never has to land on a particular line to get a value.\n * Rendered automatically by `tolle-chart` when `hover` is on.\n */\n@Component({\n selector: 'tolle-chart-tooltip',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @if (index !== null) {\n <div\n role=\"status\"\n aria-live=\"polite\"\n [class]=\"computedClass\"\n [style.left.px]=\"left\"\n [style.top.px]=\"top\">\n <p class=\"mb-1 text-xs font-medium text-foreground\">{{ xLabel }}</p>\n @for (row of rows; track $index) {\n <div class=\"flex items-center gap-2 text-xs\">\n <span class=\"h-0.5 w-3 shrink-0 rounded-full\" [style.background]=\"row.color\"></span>\n <span class=\"text-muted-foreground\">{{ row.label }}</span>\n <span class=\"ml-auto font-medium tabular-nums text-foreground\">{{ row.value }}</span>\n </div>\n }\n </div>\n }\n `,\n})\nexport class ChartTooltipComponent implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n\n /** Distance in px the tooltip sits from the crosshair. @default 12 */\n @Input() offset = 12;\n /** Assumed tooltip width in px, used to flip it near the right edge. @default 160 */\n @Input() estimatedWidth = 160;\n /** Extra Tailwind classes merged onto the tooltip via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n /** Row index the tooltip is describing, or null when nothing is hovered. */\n index: number | null = null;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe(index => {\n this.index = index;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get xLabel(): string {\n return this.index === null ? '' : (this.chart.xLabels[this.index] ?? '');\n }\n\n /** One row per series: colour key, label, and the formatted value. */\n get rows(): { label: string; value: string; color: string }[] {\n const index = this.index;\n if (index === null) return [];\n return this.chart.series.map(item => {\n const value = this.chart.valueAt(item.key, index);\n return {\n label: item.label,\n value: value == null ? '—' : this.chart.formatValue(value),\n color: this.chart.colorFor(item.key),\n };\n });\n }\n\n get left(): number {\n if (this.index === null) return 0;\n const x = this.chart.xFor(this.index);\n // Flip to the left of the crosshair rather than overflow the container.\n if (x + this.offset + this.estimatedWidth > this.chart.width) {\n return Math.max(0, x - this.offset - this.estimatedWidth);\n }\n return x + this.offset;\n }\n\n get top(): number {\n return this.chart.plotTop;\n }\n\n get computedClass() {\n return cn(\n 'pointer-events-none absolute z-10 min-w-[8rem] rounded-md border border-border bg-popover p-2 text-popover-foreground shadow-md',\n this.class\n );\n }\n}\n\nconst chartLegendVariants = cva('flex flex-wrap items-center gap-x-4 gap-y-1 pt-2', {\n variants: {\n align: { start: 'justify-start', center: 'justify-center', end: 'justify-end' },\n size: { sm: 'text-[11px]', default: 'text-xs' },\n },\n defaultVariants: { align: 'center', size: 'default' },\n});\n\nexport type ChartLegendProps = VariantProps<typeof chartLegendVariants>;\n\n/**\n * Swatch and label per series. Rendered automatically by `tolle-chart` whenever\n * there are two or more series, so identity is never carried by colour alone.\n * A single series gets no legend — the chart's own label already names it.\n */\n@Component({\n selector: 'tolle-chart-legend',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <ul [class]=\"computedClass\">\n @for (entry of entries; track $index) {\n <li class=\"flex items-center gap-1.5\">\n <span class=\"h-2 w-2 shrink-0 rounded-[2px]\" [style.background]=\"entry.color\"></span>\n <span class=\"text-muted-foreground\">{{ entry.label }}</span>\n </li>\n }\n </ul>\n `,\n})\nexport class ChartLegendComponent implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n\n /** Horizontal alignment of the legend row. @default 'center' */\n @Input() align: ChartLegendProps['align'] = 'center';\n /** Text size of the legend. @default 'default' */\n @Input() size: ChartLegendProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the legend via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** One entry per series, coloured by series identity. */\n get entries(): { label: string; color: string }[] {\n return this.chart.series.map(item => ({\n label: item.label,\n color: this.chart.colorFor(item.key),\n }));\n }\n\n get computedClass() {\n return cn(chartLegendVariants({ align: this.align, size: this.size }), this.class);\n }\n}\n\nconst chartTableVariants = cva('w-full text-left text-xs', {\n variants: {\n visible: {\n true: 'mt-4 border-collapse',\n false: 'sr-only',\n },\n },\n defaultVariants: { visible: false },\n});\n\nexport type ChartTableProps = VariantProps<typeof chartTableVariants>;\n\n/**\n * The chart's data as a real `<table>`. Visually hidden by default and pointed\n * at by the svg's `aria-describedby`, so every value the chart encodes in\n * colour stays reachable without seeing or hovering it. Set `visible` to show it.\n */\n@Component({\n selector: 'tolle-chart-table',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [attr.id]=\"tableId || null\">\n <table [class]=\"computedClass\">\n <caption class=\"sr-only\">\n {{\n caption\n }}\n </caption>\n <thead>\n <tr>\n <th\n scope=\"col\"\n class=\"border-b border-border px-2 py-1 font-medium text-muted-foreground\">\n {{ xHeader }}\n </th>\n @for (item of chart.series; track $index) {\n <th\n scope=\"col\"\n class=\"border-b border-border px-2 py-1 font-medium text-muted-foreground\">\n {{ item.label }}\n </th>\n }\n </tr>\n </thead>\n <tbody>\n @for (row of rows; track $index) {\n <tr>\n <th scope=\"row\" class=\"border-b border-border px-2 py-1 font-normal text-foreground\">\n {{ row.label }}\n </th>\n @for (cell of row.cells; track $index) {\n <td class=\"border-b border-border px-2 py-1 tabular-nums text-foreground\">\n {{ cell }}\n </td>\n }\n </tr>\n }\n </tbody>\n </table>\n </div>\n `,\n})\nexport class ChartTableComponent implements OnChanges, OnInit, OnDestroy {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n\n /** Shows the table instead of hiding it from sighted readers. @default false */\n @Input() visible = false;\n /** Id applied to the wrapper, for the chart's `aria-describedby`. @default '' */\n @Input() tableId = '';\n /** Column header for the x category. @default 'Category' */\n @Input() xHeader = 'Category';\n /** Accessible caption for the table. @default 'Chart data' */\n @Input() caption = 'Chart data';\n /** Extra Tailwind classes merged onto the table via `cn()` (last-wins). */\n @Input() class = '';\n\n protected readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** One row per x position, with a formatted cell per series. */\n get rows(): { label: string; cells: string[] }[] {\n return this.chart.xLabels.map((label, i) => ({\n label,\n cells: this.chart.series.map(item => {\n const value = this.chart.valueAt(item.key, i);\n return value == null ? '—' : this.chart.formatValue(value);\n }),\n }));\n }\n\n get computedClass() {\n return cn(chartTableVariants({ visible: this.visible }), this.class);\n }\n}\n\nconst chartVariants = cva('relative w-full', {\n variants: {\n variant: {\n default: '',\n card: 'rounded-lg border border-border bg-background p-4',\n },\n density: {\n default: '',\n compact: 'text-xs',\n },\n },\n defaultVariants: { variant: 'default', density: 'default' },\n});\n\nexport type ChartProps = VariantProps<typeof chartVariants>;\n\n/**\n * Container for a hand-rolled SVG chart. Owns the responsive `<svg>`, the\n * shared scales (via the `ChartService` it provides), the hover layer, the\n * legend and the accessible table fallback.\n *\n * Marks and axes are projected as SVG children. They use attribute selectors on\n * `svg:g` rather than element selectors, because a custom element is created in\n * the HTML namespace and an HTML element inside an `<svg>` never renders its\n * SVG subtree:\n *\n * ```html\n * <tolle-chart [data]=\"rows\" [series]=\"series\" xKey=\"month\" ariaLabel=\"Revenue\">\n * <svg:g tolle-chart-grid></svg:g>\n * <svg:g tolle-chart-y-axis></svg:g>\n * <svg:g tolle-chart-x-axis></svg:g>\n * <svg:g tolle-chart-line seriesKey=\"revenue\" curve=\"smooth\"></svg:g>\n * </tolle-chart>\n * ```\n *\n * There is deliberately no secondary y-axis: two measures on two scales in one\n * frame let the author choose where the lines cross, so the reader sees a\n * relationship the data does not contain. Use two charts, small multiples, or\n * index both series to a common base instead.\n * @new\n */\n@Component({\n selector: 'tolle-chart',\n styles: [':host { display: block; }'],\n imports: [ChartTooltipComponent, ChartLegendComponent, ChartTableComponent],\n providers: [ChartService],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\">\n <div class=\"relative w-full\" #plot>\n <svg\n role=\"img\"\n width=\"100%\"\n [attr.height]=\"height\"\n [attr.viewBox]=\"viewBox\"\n [attr.aria-label]=\"ariaLabel || null\"\n [attr.aria-describedby]=\"tableId\"\n class=\"block w-full overflow-visible\"\n (pointerleave)=\"onPointerLeave()\">\n <title>{{ ariaLabel }}</title>\n <desc>{{ description || ariaLabel }}</desc>\n\n <svg:g>\n <ng-content></ng-content>\n </svg:g>\n\n @if (crosshairX !== null) {\n <svg:line\n [attr.x1]=\"crosshairX\"\n [attr.x2]=\"crosshairX\"\n [attr.y1]=\"chart.plotTop\"\n [attr.y2]=\"chart.plotBottom\"\n class=\"pointer-events-none stroke-border\"\n stroke-width=\"1\"></svg:line>\n }\n\n @for (rect of hitRects; track $index; let i = $index) {\n <svg:rect\n [attr.x]=\"rect.x\"\n [attr.y]=\"rect.y\"\n [attr.width]=\"rect.width\"\n [attr.height]=\"rect.height\"\n fill=\"transparent\"\n (pointerenter)=\"onEnterBand(i)\"></svg:rect>\n }\n </svg>\n\n @if (hover) {\n <tolle-chart-tooltip></tolle-chart-tooltip>\n }\n </div>\n\n @if (series.length > 1) {\n <tolle-chart-legend></tolle-chart-legend>\n }\n\n <tolle-chart-table\n [tableId]=\"tableId\"\n [visible]=\"showTable\"\n [xHeader]=\"xHeader\"></tolle-chart-table>\n </div>\n `,\n})\nexport class ChartComponent implements OnInit, OnChanges, AfterViewInit, OnDestroy {\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n\n /** Rows to plot, in x order. @default [] */\n @Input() data: Record<string, any>[] = [];\n /** Series to draw, in the order they take palette steps `--chart-1`..`--chart-5`. @default [] */\n @Input() series: ChartSeries[] = [];\n /** Row key holding each row's x category label. @default '' */\n @Input() xKey = '';\n /** Height of the chart in px. @default 260 */\n @Input() height = 260;\n /** Space reserved around the plot for axis labels. @default { top: 8, right: 8, bottom: 24, left: 44 } */\n @Input() margin: ChartMargin = { top: 8, right: 8, bottom: 24, left: 44 };\n /** Accessible name for the chart, used as the svg `<title>`. @default '' */\n @Input() ariaLabel = '';\n /** Longer summary used as the svg `<desc>`. Falls back to `ariaLabel`. @default '' */\n @Input() description = '';\n /** Stacks marks on a shared baseline instead of grouping them. @default false */\n @Input() stacked = false;\n /**\n * Which physical axis carries the category vs the value. Only bars support\n * 'horizontal' — line/area marks stay vertical regardless. @default 'vertical'\n */\n @Input() orientation: ChartOrientation = 'vertical';\n /** Renders the crosshair, hit layer and shared tooltip. @default true */\n @Input() hover = true;\n /** Shows the data table instead of leaving it visually hidden. @default false */\n @Input() showTable = false;\n /** Column header the table fallback gives the x category. @default 'Category' */\n @Input() xHeader = 'Category';\n /** Visual treatment of the chart frame. @default 'default' */\n @Input() variant: ChartProps['variant'] = 'default';\n /** Text density of the chart chrome. @default 'default' */\n @Input() density: ChartProps['density'] = 'default';\n /** Extra Tailwind classes merged onto the chart via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the hovered row index, or null when the pointer leaves. */\n @Output() activeIndexChange = new EventEmitter<number | null>();\n\n @ViewChild('plot') plotRef?: ElementRef<HTMLElement>;\n\n protected readonly chart = inject(ChartService);\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscriptions = new Subscription();\n private resizeObserver?: ResizeObserver;\n\n /** Width used until the container has been measured. */\n static readonly fallbackWidth = 600;\n\n /** Id of the table fallback, referenced by the svg's `aria-describedby`. */\n readonly tableId = 'chart-table-' + chartId++;\n\n /** X of the crosshair, or null when nothing is hovered or the scale is banded. */\n crosshairX: number | null = null;\n\n ngOnInit(): void {\n this.push();\n this.subscriptions.add(this.chart.layout$.subscribe(() => this.cdr.markForCheck()));\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe(index => {\n // Bars carry their own hover state; a crosshair belongs to point scales.\n this.crosshairX =\n index != null && this.chart.mode === 'point' ? this.chart.xFor(index) : null;\n this.activeIndexChange.emit(index);\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnChanges(): void {\n this.push();\n\n // A bound `class` input is written through Angular's styling path,\n // which does not mark an OnPush view dirty on its own.\n this.cdr.markForCheck();\n }\n\n ngAfterViewInit(): void {\n const host = this.plotRef?.nativeElement;\n if (!host) return;\n this.measure(host);\n\n if (typeof ResizeObserver !== 'undefined') {\n this.resizeObserver = new ResizeObserver(() => {\n this.measure(host);\n this.cdr.markForCheck();\n });\n this.resizeObserver.observe(host);\n }\n }\n\n ngOnDestroy(): void {\n this.resizeObserver?.disconnect();\n this.resizeObserver = undefined;\n this.subscriptions.unsubscribe();\n }\n\n private measure(host: HTMLElement): void {\n const width = host.clientWidth || ChartComponent.fallbackWidth;\n if (width === this.chart.width) return;\n this.chart.configure({ width });\n }\n\n private push(): void {\n this.chart.configure({\n data: this.data ?? [],\n series: this.series ?? [],\n xKey: this.xKey,\n stacked: this.stacked,\n orientation: this.orientation,\n height: this.height,\n margin: this.margin,\n width: this.chart.width || ChartComponent.fallbackWidth,\n });\n }\n\n get viewBox(): string {\n return '0 0 ' + (this.chart.width || ChartComponent.fallbackWidth) + ' ' + this.height;\n }\n\n /**\n * One catchment rect per x position, deliberately wider than the marks it\n * covers — readers aim at a category, never at a 2px line or an 8px dot.\n * Spans the full plot in the cross direction: full height in a vertical\n * chart, full width in a horizontal one.\n */\n get hitRects(): { x: number; y: number; width: number; height: number }[] {\n if (!this.hover) return [];\n const horizontal = this.chart.orientation === 'horizontal';\n return this.data.map((_, i) => {\n const band = this.chart.hitBandFor(i);\n return horizontal\n ? { x: this.chart.plotLeft, y: band.start, width: this.chart.plotWidth, height: band.width }\n : {\n x: band.start,\n y: this.chart.plotTop,\n width: band.width,\n height: this.chart.plotHeight,\n };\n });\n }\n\n onEnterBand(index: number): void {\n this.chart.setActiveIndex(index);\n }\n\n onPointerLeave(): void {\n this.chart.setActiveIndex(null);\n }\n\n get computedClass() {\n return cn(chartVariants({ variant: this.variant, density: this.density }), this.class);\n }\n}\n\n/**\n * Rules at the value ticks, with optional rules at the category positions.\n * Orientation-aware: in a horizontal chart the value rules run vertically and\n * the (optional) category rules run horizontally — the transpose of a\n * vertical chart's grid.\n */\n@Component({\n selector: 'svg:g[tolle-chart-grid]',\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @for (line of valueLines; track $index) {\n <svg:line\n [attr.x1]=\"line.x1\"\n [attr.x2]=\"line.x2\"\n [attr.y1]=\"line.y1\"\n [attr.y2]=\"line.y2\"\n [class]=\"lineClass\"\n stroke-width=\"1\"></svg:line>\n }\n @for (line of categoryLines; track $index) {\n <svg:line\n [attr.x1]=\"line.x1\"\n [attr.x2]=\"line.x2\"\n [attr.y1]=\"line.y1\"\n [attr.y2]=\"line.y2\"\n [class]=\"lineClass\"\n stroke-width=\"1\"></svg:line>\n }\n `,\n})\nexport class ChartGridComponent extends ChartChild {\n /** Also draws a rule at every category position. @default false */\n @Input() vertical = false;\n /** Extra Tailwind classes merged onto each rule via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Rules at each value tick, spanning the plot in the cross direction. */\n get valueLines(): { x1: number; y1: number; x2: number; y2: number }[] {\n const horizontal = this.chart.orientation === 'horizontal';\n return this.chart.ticks.map(tick =>\n horizontal\n ? { x1: tick.y, x2: tick.y, y1: this.chart.plotTop, y2: this.chart.plotBottom }\n : { x1: this.chart.plotLeft, x2: this.chart.plotRight, y1: tick.y, y2: tick.y }\n );\n }\n\n /** Rules at each category position, only when `vertical` is set. */\n get categoryLines(): { x1: number; y1: number; x2: number; y2: number }[] {\n if (!this.vertical) return [];\n const horizontal = this.chart.orientation === 'horizontal';\n return this.chart.xLabels.map((_, i) => {\n const pos = this.chart.xFor(i);\n return horizontal\n ? { x1: this.chart.plotLeft, x2: this.chart.plotRight, y1: pos, y2: pos }\n : { x1: pos, x2: pos, y1: this.chart.plotTop, y2: this.chart.plotBottom };\n });\n }\n\n get lineClass(): string {\n // Recessive by design: a grid is a reading aid, never a mark.\n return cn('stroke-border opacity-60', this.class);\n }\n}\n\n/** X-axis tick labels, thinned so they never collide. */\n@Component({\n selector: 'svg:g[tolle-chart-x-axis]',\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @for (tick of visibleTicks; track $index) {\n <svg:text\n [attr.x]=\"tick.x\"\n [attr.y]=\"labelY\"\n text-anchor=\"middle\"\n dominant-baseline=\"hanging\"\n [class]=\"labelClass\">{{ tick.label }}</svg:text>\n }\n `,\n})\nexport class ChartXAxisComponent extends ChartChild {\n /**\n * Approximate px per character, used to decide when category labels would\n * collide. Only consulted in the vertical orientation. @default 7\n */\n @Input() charWidth = 7;\n /** Extra Tailwind classes merged onto each label via `cn()` (last-wins). */\n @Input() class = '';\n\n /**\n * Draw every nth label. Derived from the widest label and the space each one\n * gets, so a dense axis thins out instead of overprinting itself.\n */\n get stride(): number {\n const labels = this.chart.xLabels;\n if (labels.length === 0) return 1;\n const widest = labels.reduce((max, label) => Math.max(max, label.length), 0);\n const needed = widest * this.charWidth + 8;\n const perLabel = this.chart.plotWidth / labels.length;\n if (perLabel <= 0) return 1;\n return Math.max(1, Math.ceil(needed / perLabel));\n }\n\n /**\n * Vertical orientation: category labels, thinned so they never collide\n * (today's behaviour). Horizontal orientation: this is the bottom axis'\n * physical position, which a horizontal chart uses for its value ticks.\n */\n get visibleTicks(): { x: number; label: string }[] {\n if (this.chart.orientation === 'horizontal') {\n return this.chart.ticks.map(tick => ({ x: tick.y, label: tick.label }));\n }\n const stride = this.stride;\n return this.chart.xLabels\n .map((label, i) => ({ label, x: this.chart.xFor(i), i }))\n .filter(tick => tick.i % stride === 0);\n }\n\n get labelY(): number {\n return this.chart.plotBottom + 8;\n }\n\n get labelClass(): string {\n // Text wears a text token, never the series colour.\n return cn('fill-muted-foreground text-xs', this.class);\n }\n}\n\n/** Y-axis tick labels at the nice tick values. */\n@Component({\n selector: 'svg:g[tolle-chart-y-axis]',\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @for (tick of visibleTicks; track $index) {\n <svg:text\n [attr.x]=\"labelX\"\n [attr.y]=\"tick.y\"\n text-anchor=\"end\"\n dominant-baseline=\"middle\"\n [class]=\"labelClass\">{{ tick.label }}</svg:text>\n }\n `,\n})\nexport class ChartYAxisComponent extends ChartChild {\n /** Gap in px between the labels and the plot edge. @default 8 */\n @Input() offset = 8;\n /** Extra Tailwind classes merged onto each label via `cn()` (last-wins). */\n @Input() class = '';\n\n get labelX(): number {\n return this.chart.plotLeft - this.offset;\n }\n\n /**\n * Vertical orientation: value ticks (today's behaviour). Horizontal\n * orientation: this is the left axis' physical position, which a\n * horizontal chart uses for its category labels.\n */\n get visibleTicks(): { y: number; label: string }[] {\n if (this.chart.orientation === 'horizontal') {\n return this.chart.xLabels.map((label, i) => ({ label, y: this.chart.xFor(i) }));\n }\n return this.chart.ticks;\n }\n\n get labelClass(): string {\n return cn('fill-muted-foreground text-xs tabular-nums', this.class);\n }\n}\n\n/** A line mark for one series, optionally monotone-smoothed and dotted. */\n@Component({\n selector: 'svg:g[tolle-chart-line]',\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:path\n [attr.d]=\"path\"\n fill=\"none\"\n [attr.stroke]=\"color\"\n stroke-width=\"2\"\n stroke-linejoin=\"round\"\n stroke-linecap=\"round\"\n [class]=\"class\"></svg:path>\n @for (point of dots; track $index) {\n <svg:circle\n [attr.cx]=\"point.x\"\n [attr.cy]=\"point.y\"\n [attr.r]=\"dotRadius\"\n [attr.fill]=\"color\"\n class=\"stroke-background\"\n stroke-width=\"2\"></svg:circle>\n }\n `,\n})\nexport class ChartLineComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Interpolation between points; smooth is monotone cubic, so it never overshoots. @default 'linear' */\n @Input() curve: 'linear' | 'smooth' = 'linear';\n /** Draws a marker at every point. @default false */\n @Input() showDots = false;\n /** Marker radius in px; 4 gives the 8px minimum diameter. @default 4 */\n @Input() dotRadius = 4;\n /** Extra Tailwind classes merged onto the path via `cn()` (last-wins). */\n @Input() class = '';\n\n get path(): string {\n return this.curve === 'smooth'\n ? this.chart.smoothPath(this.seriesKey)\n : this.chart.linePath(this.seriesKey);\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n\n get dots(): { x: number; y: number; index: number }[] {\n return this.showDots ? this.chart.pointsFor(this.seriesKey) : [];\n }\n}\n\n/** A filled area for one series: a low-opacity wash under a 2px stroke. */\n@Component({\n selector: 'svg:g[tolle-chart-area]',\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <svg:path\n [attr.d]=\"areaPath\"\n [attr.fill]=\"color\"\n [attr.fill-opacity]=\"fillOpacity\"\n stroke=\"none\"></svg:path>\n <svg:path\n [attr.d]=\"linePath\"\n fill=\"none\"\n [attr.stroke]=\"color\"\n stroke-width=\"2\"\n stroke-linejoin=\"round\"\n stroke-linecap=\"round\"\n [class]=\"class\"></svg:path>\n `,\n})\nexport class ChartAreaComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Interpolation between points. @default 'linear' */\n @Input() curve: 'linear' | 'smooth' = 'linear';\n /** Opacity of the fill — a wash, never a saturated block. @default 0.1 */\n @Input() fillOpacity = 0.1;\n /** Extra Tailwind classes merged onto the stroke via `cn()` (last-wins). */\n @Input() class = '';\n\n /** An area is anchored to a baseline, so zero has to be in the domain. */\n protected override register(): void {\n this.chart.requireZero();\n }\n\n get areaPath(): string {\n return this.chart.areaPath(this.seriesKey, this.curve === 'smooth');\n }\n\n get linePath(): string {\n return this.curve === 'smooth'\n ? this.chart.smoothPath(this.seriesKey)\n : this.chart.linePath(this.seriesKey);\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n}\n\n/**\n * Builds a bar whose data end is rounded and whose baseline end stays square.\n * Handles both directions, so a negative value rounds downward.\n */\nexport function barPath(\n x: number,\n width: number,\n yBase: number,\n yValue: number,\n radius: number\n): string {\n const height = Math.abs(yBase - yValue);\n const r = Math.max(0, Math.min(radius, width / 2, height));\n const right = x + width;\n // Which way the corner curves depends on which side of the baseline we are.\n const sign = yValue <= yBase ? 1 : -1;\n const corner = yValue + sign * r;\n\n return (\n 'M ' +\n x +\n ' ' +\n yBase +\n ' L ' +\n x +\n ' ' +\n corner +\n ' Q ' +\n x +\n ' ' +\n yValue +\n ' ' +\n (x + r) +\n ' ' +\n yValue +\n ' L ' +\n (right - r) +\n ' ' +\n yValue +\n ' Q ' +\n right +\n ' ' +\n yValue +\n ' ' +\n right +\n ' ' +\n corner +\n ' L ' +\n right +\n ' ' +\n yBase +\n ' Z'\n );\n}\n\n/**\n * `barPath()` transposed for horizontal bars: fixed vertical extent\n * `[y, y+height]`, variable horizontal extent `[xBase, xValue]`, rounded at\n * the `xValue` (data) end.\n */\nexport function barPathHorizontal(\n y: number,\n height: number,\n xBase: number,\n xValue: number,\n radius: number\n): string {\n const width = Math.abs(xBase - xValue);\n const r = Math.max(0, Math.min(radius, height / 2, width));\n const bottom = y + height;\n const sign = xValue >= xBase ? 1 : -1;\n const corner = xValue - sign * r;\n\n return (\n 'M ' +\n xBase +\n ' ' +\n y +\n ' L ' +\n corner +\n ' ' +\n y +\n ' Q ' +\n xValue +\n ' ' +\n y +\n ' ' +\n xValue +\n ' ' +\n (y + r) +\n ' L ' +\n xValue +\n ' ' +\n (bottom - r) +\n ' Q ' +\n xValue +\n ' ' +\n bottom +\n ' ' +\n corner +\n ' ' +\n bottom +\n ' L ' +\n xBase +\n ' ' +\n bottom +\n ' Z'\n );\n}\n\n/**\n * A bar mark for one series. Grouped beside its siblings by default, stacked\n * when the container's `stacked` is set. The data end carries a 4px radius, the\n * baseline end stays square, and neighbours are separated by surface, not ink.\n */\n@Component({\n selector: 'svg:g[tolle-chart-bar]',\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n @for (bar of bars; track $index) {\n <svg:path\n [attr.d]=\"bar.d\"\n [attr.fill]=\"color\"\n [attr.fill-opacity]=\"bar.active ? 1 : 0.85\"></svg:path>\n }\n `,\n})\nexport class ChartBarComponent extends ChartChild {\n /** Key of the series to draw. @default '' */\n @Input() seriesKey = '';\n /** Corner radius on the data end in px. @default 4 */\n @Input() radius = 4;\n /** Largest bar thickness in px; a wider band becomes air, not a fatter bar. @default 24 */\n @Input() maxWidth = 24;\n /** Gap in px between adjacent bars and between stacked segments. @default 2 */\n @Input() gap = 2;\n /** Extra Tailwind classes merged onto each bar via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Index currently hovered, so the bar under the pointer can lift. */\n activeIndex: number | null = null;\n\n /** Bars need a band scale and a zero baseline; both belong to the whole chart. */\n protected override register(): void {\n this.chart.useBandScale();\n }\n\n override ngOnInit(): void {\n super.ngOnInit();\n this.subscriptions.add(\n this.chart.activeIndex$.subscribe(index => {\n this.activeIndex = index;\n this.cdr.markForCheck();\n })\n );\n }\n\n get color(): string {\n return this.chart.colorFor(this.seriesKey);\n }\n\n /**\n * Geometry for every bar this series contributes. `x`/`width` are the bar's\n * footprint on the category axis and `yBase`/`yValue` its extent on the\n * value axis — physical x/y when vertical (the original meaning), but\n * transposed (x holds a vertical footprint, yBase/yValue hold horizontal\n * positions) when the chart is horizontal, same as `ChartService.yFor()`.\n */\n get bars(): {\n d: string;\n active: boolean;\n x: number;\n width: number;\n yBase: number;\n yValue: number;\n }[] {\n const out: {\n d: string;\n active: boolean;\n x: number;\n width: number;\n yBase: number;\n yValue: number;\n }[] = [];\n const seriesCount = Math.max(1, this.chart.series.length);\n const found = this.chart.series.findIndex(item => item.key === this.seriesKey);\n const slotIndex = found < 0 ? 0 : found;\n const horizontal = this.chart.orientation === 'horizontal';\n\n for (let i = 0; i < this.chart.count; i++) {\n const value = this.chart.valueAt(this.seriesKey, i);\n if (value == null) continue;\n\n const band = this.chart.bandFor(i);\n let x: number;\n let width: number;\n\n if (this.chart.stacked) {\n width = Math.min(this.maxWidth, band.width);\n x = band.centre - width / 2;\n } else {\n const slot = band.width / seriesCount;\n width = Math.min(this.maxWidth, Math.max(1, slot - this.gap));\n x = band.start + slot * slotIndex + (slot - width) / 2;\n }\n\n const base = this.chart.stacked ? this.chart.stackBase(this.seriesKey, i) : 0;\n const yBase = this.chart.yFor(base);\n let yValue = this.chart.yFor(base + value);\n\n // The gap between stacked segments comes off the data end, so what\n // separates them is surface showing through rather than a stroke. Signed\n // from the resolved positions (not the raw value) so it holds under\n // both orientations, whose value axis runs in opposite directions.\n if (this.chart.stacked && Math.abs(yBase - yValue) > this.gap) {\n yValue += Math.sign(yBase - yValue) * this.gap;\n }\n\n out.push({\n d: horizontal\n ? barPathHorizontal(x, width, yBase, yValue, this.radius)\n : barPath(x, width, yBase, yValue, this.radius),\n active: this.activeIndex === i,\n x,\n width,\n yBase,\n yValue,\n });\n }\n return out;\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "chart.service.ts",
19
+ "target": "ui/chart.service.ts",
20
+ "content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject } from 'rxjs';\n\n/** One plotted series: which key to read off each row, and how to name it. */\nexport interface ChartSeries {\n key: string;\n label: string;\n}\n\n/** Space reserved around the plot area for axes and labels. */\nexport interface ChartMargin {\n top: number;\n right: number;\n bottom: number;\n left: number;\n}\n\n/** A y-axis tick: its data value, its resolved svg y, and its formatted label. */\nexport interface ChartTick {\n value: number;\n y: number;\n label: string;\n}\n\n/** A horizontal slice of the plot belonging to one x position. */\nexport interface ChartBand {\n start: number;\n centre: number;\n width: number;\n}\n\n/** How x positions are resolved: discrete bands (bars) or points (line/area). */\nexport type ChartScaleMode = 'point' | 'band';\n\n/**\n * Which physical axis carries the category vs the value. Only the band scale\n * (bars) supports 'horizontal' — line/area stay point-scale and vertical,\n * same as Tremor never offers a horizontal line/area chart either.\n */\nexport type ChartOrientation = 'vertical' | 'horizontal';\n\n/** Number of chart palette steps that exist. A 6th series does not get a colour. */\nexport const CHART_COLOR_LIMIT = 5;\n\n/**\n * The paint used for a series past `CHART_COLOR_LIMIT`.\n *\n * The palette is validated for lightness, chroma and colour-blind separation at\n * exactly five steps; generating a sixth would land outside that validation, and\n * wrapping back to step 1 would make two series share an identity. Both are\n * worse than dropping to a neutral, so the overflow is explicitly uncoloured.\n */\nexport const CHART_OVERFLOW_COLOR = 'rgb(var(--muted-foreground))';\n\n/**\n * Hands out palette steps by entity key rather than by position.\n *\n * A key keeps its slot for the lifetime of the scale, which is what makes\n * colour follow the entity: filtering a series out and back in, or removing an\n * earlier one, must never repaint the survivors. Past the fifth distinct key\n * the scale stops colouring rather than inventing a step or wrapping around.\n */\nexport class ChartColorScale {\n private readonly slots = new Map<string, number>();\n private next = 0;\n\n /** Reserves slots for `keys` in order, leaving already-known keys alone. */\n register(keys: string[]): void {\n for (const key of keys) {\n if (!this.slots.has(key)) this.slots.set(key, this.next++);\n }\n }\n\n /** Paint for `key`, registering it on first sight. */\n colorFor(key: string): string {\n let slot = this.slots.get(key);\n if (slot === undefined) {\n slot = this.next++;\n this.slots.set(key, slot);\n }\n if (slot >= CHART_COLOR_LIMIT) return CHART_OVERFLOW_COLOR;\n return 'rgb(var(--chart-' + (slot + 1) + '))';\n }\n\n /** True when `key` fell past the palette and renders neutral. */\n isOverflow(key: string): boolean {\n const slot = this.slots.get(key);\n return slot !== undefined && slot >= CHART_COLOR_LIMIT;\n }\n\n /** Slot index assigned to `key`, or undefined when it is unknown. */\n slotOf(key: string): number | undefined {\n return this.slots.get(key);\n }\n}\n\n/**\n * Owns layout, scales and colour assignment for one `tolle-chart`, so every\n * child draws into the same coordinate space. Renders nothing itself.\n *\n * Provided on `ChartComponent`, so each chart gets its own instance.\n */\n@Injectable()\nexport class ChartService {\n /** Rows being plotted, in x order. */\n data: Record<string, any>[] = [];\n /** Series definitions, in the order they should take palette steps. */\n series: ChartSeries[] = [];\n /** Row key holding the x category/label. */\n xKey = '';\n /** Whether marks stack on a shared baseline rather than sitting side by side. */\n stacked = false;\n /** Full svg width in px, tracked from the container's ResizeObserver. */\n width = 0;\n /** Full svg height in px. */\n height = 260;\n /** Space reserved around the plot area. */\n margin: ChartMargin = { top: 8, right: 8, bottom: 24, left: 40 };\n /** Fraction of a band's step spent on padding, split either side. */\n bandPadding = 0.2;\n /**\n * Which physical axis is the category axis. Bars flip this via `configure()`;\n * every other geometry getter branches on it internally, defaulting to the\n * exact vertical formula when unset, so existing vertical charts are unaffected.\n */\n orientation: ChartOrientation = 'vertical';\n\n private scaleMode: ChartScaleMode = 'point';\n private zeroRequired = false;\n\n private domainMin = 0;\n private domainMax = 1;\n private tickValues: number[] = [];\n private tickStep = 1;\n\n /** Palette assignment, keyed by series identity. */\n private readonly colors = new ChartColorScale();\n\n private readonly layoutSubject = new BehaviorSubject<number>(0);\n /** Emits a version number whenever scales, dimensions or data change. */\n readonly layout$ = this.layoutSubject.asObservable();\n\n private readonly activeIndexSubject = new BehaviorSubject<number | null>(null);\n /** Index of the x position under the pointer, or null when not hovering. */\n readonly activeIndex$ = this.activeIndexSubject.asObservable();\n\n private layoutVersion = 0;\n private emitScheduled = false;\n\n // ---------------------------------------------------------------- geometry\n\n get plotLeft(): number {\n return this.margin.left;\n }\n\n get plotTop(): number {\n return this.margin.top;\n }\n\n get plotWidth(): number {\n return Math.max(0, this.width - this.margin.left - this.margin.right);\n }\n\n get plotHeight(): number {\n return Math.max(0, this.height - this.margin.top - this.margin.bottom);\n }\n\n get plotRight(): number {\n return this.plotLeft + this.plotWidth;\n }\n\n get plotBottom(): number {\n return this.plotTop + this.plotHeight;\n }\n\n /** Number of x positions. */\n get count(): number {\n return this.data.length;\n }\n\n /** How x is currently resolved. Bars flip this to 'band'. */\n get mode(): ChartScaleMode {\n return this.scaleMode;\n }\n\n /** Resolved y ticks, with svg coordinates and formatted labels. */\n get ticks(): ChartTick[] {\n return this.tickValues.map((value) => ({\n value,\n y: this.yFor(value),\n label: this.formatValue(value),\n }));\n }\n\n /** Current y domain as [min, max]. */\n get domain(): [number, number] {\n return [this.domainMin, this.domainMax];\n }\n\n /** The x-axis category labels, one per row. */\n get xLabels(): string[] {\n return this.data.map((row) => {\n const raw = row?.[this.xKey];\n return raw == null ? '' : String(raw);\n });\n }\n\n // ------------------------------------------------------------ registration\n\n /**\n * Replaces the chart's configuration and recomputes the scales.\n * Called by the container on input changes and on resize.\n */\n configure(config: Partial<Pick<ChartService,\n 'data' | 'series' | 'xKey' | 'stacked' | 'width' | 'height' | 'margin' | 'bandPadding' | 'orientation'>>): void {\n Object.assign(this, config);\n if (config.series) this.syncColorSlots(config.series);\n this.recompute();\n }\n\n /** Switches x to a band scale. Bars call this; it also forces zero into the domain. */\n useBandScale(): void {\n if (this.scaleMode === 'band' && this.zeroRequired) return;\n this.scaleMode = 'band';\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Forces zero into the y domain, for marks anchored to a baseline. */\n requireZero(): void {\n if (this.zeroRequired) return;\n this.zeroRequired = true;\n this.recompute();\n }\n\n /** Reserves palette slots in series order, leaving known keys untouched. */\n private syncColorSlots(series: ChartSeries[]): void {\n this.colors.register(series.map((item) => item.key));\n }\n\n /**\n * Paint for a series, keyed by identity rather than by index in the visible\n * array. Past the fifth distinct key this returns the neutral overflow colour.\n */\n colorFor(seriesKey: string): string {\n return this.colors.colorFor(seriesKey);\n }\n\n /** True when this series fell past the palette and renders neutral. */\n isOverflowSeries(seriesKey: string): boolean {\n return this.colors.isOverflow(seriesKey);\n }\n\n // -------------------------------------------------------------- the scales\n\n /** Reads a series value off a row, or null when it is missing/non-numeric. */\n valueAt(seriesKey: string, index: number): number | null {\n const raw = this.data[index]?.[seriesKey];\n if (raw == null || raw === '') return null;\n const num = Number(raw);\n return Number.isFinite(num) ? num : null;\n }\n\n /** Sum of every series below `seriesKey` at `index`, for stacked marks. */\n stackBase(seriesKey: string, index: number): number {\n if (!this.stacked) return 0;\n let base = 0;\n for (const item of this.series) {\n if (item.key === seriesKey) break;\n base += this.valueAt(item.key, index) ?? 0;\n }\n return base;\n }\n\n /** Sum of every series at `index`. */\n stackTotal(index: number): number {\n let total = 0;\n for (const item of this.series) total += this.valueAt(item.key, index) ?? 0;\n return total;\n }\n\n /**\n * Maps a data value to a position along the *value* axis — svg y when\n * vertical (svg y grows downward, so the domain maximum lands on `plotTop`),\n * svg x when horizontal (the domain maximum lands on `plotRight`).\n */\n yFor(value: number): number {\n const span = this.domainMax - this.domainMin;\n if (this.orientation === 'horizontal') {\n if (span <= 0) return this.plotLeft;\n const ratio = (value - this.domainMin) / span;\n return this.plotLeft + ratio * this.plotWidth;\n }\n if (span <= 0) return this.plotBottom;\n const ratio = (value - this.domainMin) / span;\n return this.plotBottom - ratio * this.plotHeight;\n }\n\n /** The value-axis position of the zero line, clamped into the plot. */\n get baselineY(): number {\n const zero = Math.min(Math.max(0, this.domainMin), this.domainMax);\n return this.yFor(zero);\n }\n\n /** Length of the plot along the category axis: `plotHeight` when horizontal, else `plotWidth`. */\n private get categoryAxisLength(): number {\n return this.orientation === 'horizontal' ? this.plotHeight : this.plotWidth;\n }\n\n /** Origin of the plot along the category axis: `plotTop` when horizontal, else `plotLeft`. */\n private get categoryAxisOrigin(): number {\n return this.orientation === 'horizontal' ? this.plotTop : this.plotLeft;\n }\n\n /** Width of one band, excluding its padding. Meaningful in band mode. */\n get bandWidth(): number {\n if (this.count === 0) return 0;\n return this.bandStep * (1 - this.bandPadding);\n }\n\n /** Distance between adjacent band starts. Bands tile the plot exactly. */\n get bandStep(): number {\n if (this.count === 0) return 0;\n return this.categoryAxisLength / this.count;\n }\n\n /**\n * Geometry of the band at `index`, along the category axis — x when\n * vertical (today's meaning), y when horizontal.\n */\n bandFor(index: number): ChartBand {\n const step = this.bandStep;\n const width = this.bandWidth;\n const start = this.categoryAxisOrigin + index * step + (step - width) / 2;\n return { start, centre: start + width / 2, width };\n }\n\n /** x of the data point at `index` on the point scale. */\n pointX(index: number): number {\n if (this.count === 0) return this.plotLeft;\n if (this.count === 1) return this.plotLeft + this.plotWidth / 2;\n return this.plotLeft + (index * this.plotWidth) / (this.count - 1);\n }\n\n /** x of `index` under whichever scale the chart is currently using. */\n xFor(index: number): number {\n return this.scaleMode === 'band' ? this.bandFor(index).centre : this.pointX(index);\n }\n\n /**\n * The pointer catchment area for `index` — deliberately wider than the mark.\n * In band mode it is the whole band step; on the point scale it runs to the\n * midpoints of the neighbouring points, so the pointer only has to be nearest.\n */\n hitBandFor(index: number): ChartBand {\n if (this.scaleMode === 'band') {\n const step = this.bandStep;\n const start = this.categoryAxisOrigin + index * step;\n return { start, centre: start + step / 2, width: step };\n }\n const x = this.pointX(index);\n const prev = index > 0 ? this.pointX(index - 1) : this.plotLeft - (this.plotRight - this.plotLeft);\n const next =\n index < this.count - 1 ? this.pointX(index + 1) : this.plotRight + (this.plotRight - this.plotLeft);\n const start = Math.max(this.plotLeft, (prev + x) / 2);\n const end = Math.min(this.plotRight, (x + next) / 2);\n return { start, centre: x, width: Math.max(0, end - start) };\n }\n\n // --------------------------------------------------------------- hovering\n\n /** Index currently under the pointer, or null. */\n get activeIndex(): number | null {\n return this.activeIndexSubject.value;\n }\n\n setActiveIndex(index: number | null): void {\n if (this.activeIndexSubject.value === index) return;\n this.activeIndexSubject.next(index);\n }\n\n // ----------------------------------------------------------------- domains\n\n private recompute(): void {\n const values: number[] = [];\n\n if (this.stacked) {\n for (let i = 0; i < this.count; i++) values.push(this.stackTotal(i));\n // A stack still has to show its own floor when a series goes negative.\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null && v < 0) values.push(v);\n }\n }\n } else {\n for (const item of this.series) {\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(item.key, i);\n if (v != null) values.push(v);\n }\n }\n }\n\n if (this.zeroRequired) values.push(0);\n\n const [min, max, step, ticks] = niceScale(values);\n this.domainMin = min;\n this.domainMax = max;\n this.tickStep = step;\n this.tickValues = ticks;\n\n // Only tell subscribers when something they would draw differently actually\n // changed. `configure()` is called from ngOnChanges, and an app binding\n // `[data]=\"items.filter(...)\"` hands us a fresh array reference on every\n // change-detection pass — an ordinary thing to write. Emitting on every call\n // made subscribers markForCheck, which scheduled another pass, which called\n // configure again: the cycle never closed and the page locked up.\n const signature = this.layoutSignature(values);\n if (signature === this.lastSignature) return;\n this.lastSignature = signature;\n\n this.scheduleEmit();\n }\n\n /** Everything a subscriber would render differently, flattened for comparison. */\n private lastSignature = '';\n\n private layoutSignature(values: number[]): string {\n return [\n this.count,\n this.xKey,\n this.stacked,\n this.width,\n this.height,\n this.bandPadding,\n this.scaleMode,\n this.orientation,\n this.zeroRequired,\n this.margin.top, this.margin.right, this.margin.bottom, this.margin.left,\n this.domainMin, this.domainMax, this.tickStep,\n this.tickValues.join(','),\n this.series.map((s) => s.key + ':' + s.label).join(','),\n this.xLabels.join('\u0001'),\n values.join(','),\n ].join('\u0000');\n }\n\n /**\n * Coalesces layout notifications into a microtask, so several children\n * registering in the same change-detection pass produce one emission after\n * they have all settled rather than one each mid-pass.\n */\n private scheduleEmit(): void {\n this.layoutVersion++;\n if (this.emitScheduled) return;\n this.emitScheduled = true;\n queueMicrotask(() => {\n this.emitScheduled = false;\n this.layoutSubject.next(this.layoutVersion);\n });\n }\n\n /** Formats a value for a tick label at the current tick step's precision. */\n formatValue(value: number): string {\n const decimals = decimalsFor(this.tickStep);\n return value.toLocaleString('en-US', {\n minimumFractionDigits: decimals,\n maximumFractionDigits: decimals,\n });\n }\n\n // ------------------------------------------------------------------- paths\n\n /** Straight-segment path through the series' defined points. */\n linePath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), false);\n }\n\n /** Monotone-cubic path through the series' defined points. */\n smoothPath(seriesKey: string): string {\n return buildPath(this.pointsFor(seriesKey), true);\n }\n\n /**\n * Filled-area path: the series' line, closed back along its baseline. When\n * stacked, the floor is the top of the series below rather than zero.\n */\n areaPath(seriesKey: string, smooth: boolean): string {\n const points = this.pointsFor(seriesKey);\n if (points.length === 0) return '';\n\n const top = buildPath(points, smooth);\n const floor = points\n .map((p) => {\n const base = this.stacked ? this.stackBase(seriesKey, p.index) : 0;\n return { x: p.x, y: this.yFor(base), index: p.index };\n })\n .reverse();\n const bottom = buildPath(floor, smooth);\n // Re-enter the reversed floor with a line, then close.\n return top + ' L ' + floor[0].x + ' ' + floor[0].y + ' ' + bottom.replace(/^M/, 'L') + ' Z';\n }\n\n /** Resolved svg points for a series, skipping rows where it has no value. */\n pointsFor(seriesKey: string): { x: number; y: number; index: number }[] {\n const points: { x: number; y: number; index: number }[] = [];\n for (let i = 0; i < this.count; i++) {\n const v = this.valueAt(seriesKey, i);\n if (v == null) continue;\n const stacked = this.stacked ? this.stackBase(seriesKey, i) + v : v;\n points.push({ x: this.xFor(i), y: this.yFor(stacked), index: i });\n }\n return points;\n }\n}\n\n// ------------------------------------------------------------- pure helpers\n\n/** Decimal places worth showing for a given tick step. */\nexport function decimalsFor(step: number): number {\n if (!Number.isFinite(step) || step <= 0) return 0;\n return Math.max(0, Math.min(6, -Math.floor(Math.log10(step))));\n}\n\n/**\n * Rounds a raw step up to the next 1/2/5 x 10^n.\n *\n * Dividing the range by a tick count instead would produce steps like 19.4 and\n * so axis labels like 19.4 / 38.8 / 58.2, which nobody can read a value off.\n */\nexport function niceStep(rawStep: number): number {\n if (!Number.isFinite(rawStep) || rawStep <= 0) return 1;\n const magnitude = Math.pow(10, Math.floor(Math.log10(rawStep)));\n const normalised = rawStep / magnitude;\n let multiplier: number;\n if (normalised <= 1) multiplier = 1;\n else if (normalised <= 2) multiplier = 2;\n else if (normalised <= 5) multiplier = 5;\n else multiplier = 10;\n return multiplier * magnitude;\n}\n\n/**\n * Builds a readable y scale over `values`: returns `[min, max, step, ticks]`\n * with the domain snapped outward to whole steps.\n *\n * Degenerate inputs (empty, all-identical) still produce a usable one-unit\n * domain rather than a zero-height plot.\n */\nexport function niceScale(values: number[], targetTicks = 5): [number, number, number, number[]] {\n const finite = values.filter((v) => Number.isFinite(v));\n\n let min = finite.length ? Math.min(...finite) : 0;\n let max = finite.length ? Math.max(...finite) : 1;\n\n if (min === max) {\n // A flat or single-point domain has no span to divide; open it to one unit\n // around the value so the mark sits on a real axis instead of on the floor.\n if (min === 0) {\n min = 0;\n max = 1;\n } else if (min > 0) {\n min = Math.min(0, min);\n max = max === 0 ? 1 : max;\n if (min === max) max = min + 1;\n } else {\n max = Math.max(0, max);\n if (min === max) min = max - 1;\n }\n }\n\n const step = niceStep((max - min) / targetTicks);\n // Nudge before rounding outward: 100/20 lands a hair above 5 in binary\n // floating point, which would otherwise add a phantom tick at 120.\n const epsilon = 1e-9;\n const niceMin = Math.floor(min / step + epsilon) * step;\n const niceMax = Math.ceil(max / step - epsilon) * step;\n const decimals = decimalsFor(step);\n\n const ticks: number[] = [];\n const total = Math.round((niceMax - niceMin) / step);\n for (let i = 0; i <= total; i++) {\n ticks.push(Number((niceMin + i * step).toFixed(decimals)));\n }\n\n return [Number(niceMin.toFixed(decimals)), Number(niceMax.toFixed(decimals)), step, ticks];\n}\n\n/**\n * Monotone-cubic tangents (Fritsch-Carlson). Limiting each tangent to the\n * neighbouring secants is what stops the curve overshooting: a plain\n * Catmull-Rom spline bulges past the data and invents peaks that were never\n * measured, which on a chart is a false statement about the numbers.\n */\nexport function monotoneTangents(xs: number[], ys: number[]): number[] {\n const n = xs.length;\n if (n < 2) return new Array(n).fill(0);\n\n const secants: number[] = [];\n for (let i = 0; i < n - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n secants.push(dx === 0 ? 0 : (ys[i + 1] - ys[i]) / dx);\n }\n\n const m: number[] = new Array(n);\n m[0] = secants[0];\n m[n - 1] = secants[n - 2];\n for (let i = 1; i < n - 1; i++) m[i] = (secants[i - 1] + secants[i]) / 2;\n\n for (let i = 0; i < n - 1; i++) {\n if (secants[i] === 0) {\n // Flat segment: both ends must be flat or the curve leaves the data range.\n m[i] = 0;\n m[i + 1] = 0;\n continue;\n }\n const a = m[i] / secants[i];\n const b = m[i + 1] / secants[i];\n // Negative ratios mean the tangent points away from the secant.\n if (a < 0) m[i] = 0;\n if (b < 0) m[i + 1] = 0;\n const sum = a * a + b * b;\n if (sum > 9) {\n const t = 3 / Math.sqrt(sum);\n m[i] = t * a * secants[i];\n m[i + 1] = t * b * secants[i];\n }\n }\n\n return m;\n}\n\n/** Turns resolved points into an svg path, optionally monotone-smoothed. */\nexport function buildPath(points: { x: number; y: number }[], smooth: boolean): string {\n if (points.length === 0) return '';\n if (points.length === 1) return 'M ' + points[0].x + ' ' + points[0].y;\n\n const head = 'M ' + points[0].x + ' ' + points[0].y;\n if (!smooth) {\n return head + points.slice(1).map((p) => ' L ' + p.x + ' ' + p.y).join('');\n }\n\n const xs = points.map((p) => p.x);\n const ys = points.map((p) => p.y);\n const m = monotoneTangents(xs, ys);\n\n let d = head;\n for (let i = 0; i < points.length - 1; i++) {\n const dx = xs[i + 1] - xs[i];\n const c1x = xs[i] + dx / 3;\n const c1y = ys[i] + (m[i] * dx) / 3;\n const c2x = xs[i + 1] - dx / 3;\n const c2y = ys[i + 1] - (m[i + 1] * dx) / 3;\n d += ' C ' + c1x + ' ' + c1y + ' ' + c2x + ' ' + c2y + ' ' + xs[i + 1] + ' ' + ys[i + 1];\n }\n return d;\n}\n"
21
+ },
22
+ {
23
+ "path": "utils/cn.ts",
24
+ "target": "ui/utils/cn.ts",
25
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
26
+ }
27
+ ]
28
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "checkbox",
3
+ "type": "registry:ui",
4
+ "title": "Checkbox",
5
+ "description": "Checkbox component with size and checked options.",
6
+ "category": "forms",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "checkbox.component.ts",
14
+ "target": "ui/checkbox.component.ts",
15
+ "content": "import { Component, Input, forwardRef, ChangeDetectorRef } from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\nconst checkboxVariants = cva(\n 'group flex shrink-0 cursor-pointer items-center justify-center p-0 rounded-sm border border-primary ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',\n {\n variants: {\n size: {\n xs: 'h-3.5 w-3.5',\n sm: 'h-4 w-4',\n default: 'h-5 w-5',\n lg: 'h-6 w-6',\n },\n checked: {\n true: 'bg-primary text-primary-foreground',\n false: 'bg-transparent',\n },\n },\n defaultVariants: {\n size: 'default',\n checked: false,\n },\n }\n);\n\nexport type CheckboxProps = VariantProps<typeof checkboxVariants>;\n\n@Component({\n selector: 'tolle-checkbox',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => CheckboxComponent),\n multi: true,\n },\n ],\n template: `\n <button\n type=\"button\"\n role=\"checkbox\"\n [attr.aria-checked]=\"checked\"\n [attr.data-state]=\"checked ? 'checked' : 'unchecked'\"\n [disabled]=\"disabled\"\n (click)=\"toggle()\"\n [class]=\"computedClass\">\n @if (checked) {\n <i\n class=\"ri-check-line font-bold\"\n [class]=\"size === 'xs' ? 'text-[10px]' : 'text-[14px]'\"\n aria-hidden=\"true\"></i>\n }\n </button>\n `,\n})\nexport class CheckboxComponent implements ControlValueAccessor {\n /** Extra Tailwind classes merged onto the checkbox via `cn()` (last-wins). */\n @Input() class = '';\n /** Disables the checkbox and blocks toggling. @default false */\n @Input() disabled = false;\n /** Size of the checkbox. @default 'default' */\n @Input() size: CheckboxProps['size'] = 'default';\n\n checked = false;\n\n onChange: any = () => {};\n onTouched: any = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n get computedClass() {\n return cn(checkboxVariants({ size: this.size, checked: this.checked }), this.class);\n }\n\n toggle() {\n if (this.disabled) return;\n this.checked = !this.checked;\n this.onChange(this.checked);\n this.onTouched();\n }\n\n // --- ControlValueAccessor ---\n writeValue(value: boolean): void {\n this.checked = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: any): void {\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"
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": "checkpoint",
3
+ "type": "registry:ui",
4
+ "title": "Checkpoint",
5
+ "description": "Checkpoint component with active and size options.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "checkpoint.component.ts",
14
+ "target": "ui/checkpoint.component.ts",
15
+ "content": "import {\n ChangeDetectionStrategy,\n Component,\n EventEmitter,\n Input,\n Output,\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 checkpointPillVariants = cva(\n 'inline-flex max-w-full items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium transition-colors ' +\n 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 ' +\n 'disabled:pointer-events-none disabled:opacity-50',\n {\n variants: {\n active: {\n true: 'border-primary/50 bg-primary/10 text-primary',\n false:\n 'border-border bg-background text-muted-foreground hover:border-primary/40 hover:text-foreground',\n },\n size: {\n sm: 'px-2 py-0.5 text-[11px]',\n default: 'px-2.5 py-1 text-xs',\n },\n },\n defaultVariants: { active: false, size: 'default' },\n }\n);\n\nexport type CheckpointProps = VariantProps<typeof checkpointPillVariants>;\n\n/**\n * A labelled marker in a conversation that the user can rewind to. Renders as a\n * horizontal rule with a centred, clickable pill.\n *\n * ```html\n * <tolle-checkpoint label=\"Before refactor\" timestamp=\"14:02\" (restore)=\"rewind()\"></tolle-checkpoint>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-checkpoint',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" [attr.data-active]=\"active ? '' : null\">\n <span class=\"h-px flex-1 bg-border\" aria-hidden=\"true\"></span>\n\n <button\n type=\"button\"\n [disabled]=\"disabled\"\n [attr.aria-label]=\"ariaLabel || restoreLabel + ': ' + label\"\n [attr.aria-current]=\"active ? 'step' : null\"\n [class]=\"pillClass\"\n (click)=\"restore.emit()\">\n <i [class]=\"icon + ' shrink-0'\" aria-hidden=\"true\"></i>\n <span class=\"truncate\">{{ label }}</span>\n @if (timestamp) {\n <span class=\"shrink-0 text-muted-foreground\">{{ timestamp }}</span>\n }\n <ng-content></ng-content>\n </button>\n\n <span class=\"h-px flex-1 bg-border\" aria-hidden=\"true\"></span>\n </div>\n `,\n})\nexport class CheckpointComponent 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 /** Name of the checkpoint shown in the pill. @default 'Checkpoint' */\n @Input() label = 'Checkpoint';\n /** Pre-formatted time shown after the label. @default '' */\n @Input() timestamp = '';\n /** Highlights this checkpoint as the one currently restored to. @default false */\n @Input() active = false;\n /** Prevents the pill from being activated. @default false */\n @Input() disabled = false;\n /** Verb used to build the pill's accessible name. @default 'Restore checkpoint' */\n @Input() restoreLabel = 'Restore checkpoint';\n /** Overrides the pill's accessible name entirely. @default '' */\n @Input() ariaLabel = '';\n /** Remixicon class shown before the label. @default 'ri-history-line' */\n @Input() icon = 'ri-history-line';\n /** Size of the pill. @default 'default' */\n @Input() size: CheckpointProps['size'] = 'default';\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the user asks to restore this checkpoint. */\n @Output() restore = new EventEmitter<void>();\n\n get computedClass() {\n return cn('flex w-full items-center gap-3', this.class);\n }\n\n get pillClass() {\n return cn(checkpointPillVariants({ active: this.active, size: this.size }));\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "utils/cn.ts",
19
+ "target": "ui/utils/cn.ts",
20
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "collapsible",
3
+ "type": "registry:ui",
4
+ "title": "Collapsible",
5
+ "description": "Collapsible component.",
6
+ "category": "layout",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "collapsible.component.ts",
12
+ "target": "ui/collapsible.component.ts",
13
+ "content": "import { Component, Input, Output, EventEmitter, Injectable, inject } from '@angular/core';\n\nimport { cn } from './utils/cn';\nimport { BehaviorSubject } from 'rxjs';\nimport { trigger, state, style, transition, animate } from '@angular/animations';\n\n@Injectable()\nclass CollapsibleService {\n private openSubject = new BehaviorSubject<boolean>(false);\n open$ = this.openSubject.asObservable();\n\n setOpen(value: boolean) {\n this.openSubject.next(value);\n }\n\n getOpen() {\n return this.openSubject.getValue();\n }\n\n toggle() {\n this.setOpen(!this.getOpen());\n }\n}\n\n@Component({\n selector: 'tolle-collapsible',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [CollapsibleService],\n template: `<ng-content></ng-content>`,\n host: {\n '[class]': 'computedClass',\n },\n})\nexport class CollapsibleComponent {\n @Input() set open(val: boolean) {\n this.collapsibleService.setOpen(val);\n }\n @Output() openChange = new EventEmitter<boolean>();\n @Input() class: string = '';\n\n private collapsibleService = inject(CollapsibleService);\n\n ngOnInit() {\n this.collapsibleService.open$.subscribe(val => {\n this.openChange.emit(val);\n });\n }\n\n get computedClass() {\n return cn('w-full', this.class);\n }\n}\n\n@Component({\n selector: 'tolle-collapsible-trigger',\n imports: [],\n template: `<ng-content></ng-content>`,\n host: {\n role: 'button',\n '[attr.tabindex]': '0',\n '[attr.aria-expanded]': 'isOpen',\n '[attr.data-state]': 'isOpen ? \"open\" : \"closed\"',\n '(click)': 'onToggle()',\n '(keydown.enter)': 'onToggle()',\n '(keydown.space)': 'onToggle(); $event.preventDefault()',\n '[class]': 'computedClass',\n },\n})\nexport class CollapsibleTriggerComponent {\n @Input() class: string = '';\n\n private collapsibleService = inject(CollapsibleService);\n isOpen = false;\n\n constructor() {\n this.collapsibleService.open$.subscribe(open => {\n this.isOpen = open;\n });\n }\n\n onToggle() {\n this.collapsibleService.toggle();\n }\n\n get computedClass() {\n return cn(\n 'cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring',\n this.class\n );\n }\n}\n\n@Component({\n selector: 'tolle-collapsible-content',\n imports: [],\n template: `\n <div [@expandCollapse]=\"isOpen ? 'open' : 'closed'\" class=\"overflow-hidden\">\n <ng-content></ng-content>\n </div>\n `,\n animations: [\n trigger('expandCollapse', [\n state('closed', style({ height: '0px', opacity: 0 })),\n state('open', style({ height: '*', opacity: 1 })),\n transition('closed <=> open', animate('300ms cubic-bezier(0.4, 0, 0.2, 1)')),\n ]),\n ],\n host: {\n '[attr.data-state]': 'isOpen ? \"open\" : \"closed\"',\n // `inert` (not `hidden`) so the height/opacity collapse animation still runs\n // while removing collapsed content from the tab order + pointer interaction.\n '[attr.inert]': 'isOpen ? null : \"\"',\n '[class]': 'computedClass',\n },\n})\nexport class CollapsibleContentComponent {\n @Input() class: string = '';\n\n private collapsibleService = inject(CollapsibleService);\n isOpen = false;\n\n constructor() {\n this.collapsibleService.open$.subscribe(open => {\n this.isOpen = open;\n });\n }\n\n get computedClass() {\n return cn('', this.class);\n }\n}\n"
14
+ },
15
+ {
16
+ "path": "utils/cn.ts",
17
+ "target": "ui/utils/cn.ts",
18
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "combobox",
3
+ "type": "registry:ui",
4
+ "title": "Combobox",
5
+ "description": "Combobox component with size and invalid options.",
6
+ "category": "forms",
7
+ "registryDependencies": [
8
+ "command"
9
+ ],
10
+ "dependencies": [
11
+ "@floating-ui/dom",
12
+ "class-variance-authority"
13
+ ],
14
+ "files": [
15
+ {
16
+ "path": "combobox.component.ts",
17
+ "target": "ui/combobox.component.ts",
18
+ "content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ElementRef,\n ViewChild,\n forwardRef,\n ChangeDetectorRef,\n OnDestroy,\n} from '@angular/core';\n\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';\nimport { computePosition, flip, shift, offset, size, autoUpdate } from '@floating-ui/dom';\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\nimport {\n CommandComponent,\n CommandInputComponent,\n CommandListComponent,\n CommandEmptyComponent,\n CommandItemComponent,\n} from './command.component';\n\nconst comboboxTriggerVariants = 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\nexport type ComboboxProps = VariantProps<typeof comboboxTriggerVariants>;\n\nexport type ComboboxOption = {\n label: string;\n value: any;\n disabled?: boolean;\n /** Extra terms that should also match this option when searching. */\n keywords?: string[];\n};\n\n/**\n * A searchable single-select: a button that opens a `tolle-command` list in a\n * floating panel. Implements `ControlValueAccessor`, so it works with\n * `ngModel` and reactive forms.\n *\n * Use `tolle-select` when the list is short and needs no search, and\n * `tolle-multi-select` when more than one value can be chosen.\n * @new\n */\n@Component({\n selector: 'tolle-combobox',\n styles: [':host { display: block; }'],\n imports: [\n CommandComponent,\n CommandInputComponent,\n CommandListComponent,\n CommandEmptyComponent,\n CommandItemComponent,\n ],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => ComboboxComponent),\n multi: true,\n },\n ],\n template: `\n <button\n #trigger\n type=\"button\"\n role=\"combobox\"\n aria-haspopup=\"listbox\"\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', selectedLabel ? 'text-foreground' : 'text-muted-foreground')\">\n {{ selectedLabel || placeholder }}\n </span>\n <i\n class=\"ri-expand-up-down-line shrink-0 text-muted-foreground transition-transform\"\n aria-hidden=\"true\"></i>\n </button>\n\n @if (isOpen) {\n <div #panel [id]=\"panelId\" class=\"fixed left-0 top-0 z-[9999]\" style=\"visibility: hidden\">\n <div class=\"overflow-hidden rounded-md border border-border bg-popover shadow-md\">\n <tolle-command\n [shouldFilter]=\"shouldFilter\"\n (selected)=\"onSelect($event)\"\n (queryChange)=\"searchChange.emit($event)\">\n <tolle-command-input [placeholder]=\"searchPlaceholder\"></tolle-command-input>\n <tolle-command-list>\n <tolle-command-empty>{{ emptyMessage }}</tolle-command-empty>\n @for (option of options; track option) {\n <tolle-command-item\n [value]=\"option.value\"\n [label]=\"option.label\"\n [keywords]=\"option.keywords || []\"\n [disabled]=\"option.disabled || false\">\n <i\n class=\"ri-check-line shrink-0\"\n [class.opacity-0]=\"option.value !== value\"\n aria-hidden=\"true\"></i>\n <span class=\"truncate\">{{ option.label }}</span>\n </tolle-command-item>\n }\n </tolle-command-list>\n </tolle-command>\n </div>\n </div>\n }\n `,\n})\nexport class ComboboxComponent implements ControlValueAccessor, OnDestroy {\n /** Options to choose from. @default [] */\n @Input() options: ComboboxOption[] = [];\n /** Text shown on the trigger when nothing is selected. @default 'Select an option…' */\n @Input() placeholder = 'Select an option…';\n /** Placeholder inside the search box. @default 'Search…' */\n @Input() searchPlaceholder = 'Search…';\n /** Message shown when the query matches no option. @default 'No results found.' */\n @Input() emptyMessage = 'No results found.';\n /** Height and text size of the trigger. @default 'default' */\n @Input() size: ComboboxProps['size'] = 'default';\n /** Applies the destructive border and focus ring. @default false */\n @Input() invalid = false;\n /** Disables the control. @default false */\n @Input() disabled = false;\n /** Set false to filter the options yourself, e.g. server-side. @default true */\n @Input() shouldFilter = true;\n /** Close the panel after a value is chosen. @default true */\n @Input() closeOnSelect = true;\n /** Where the panel opens relative to the trigger. @default 'bottom-start' */\n @Input() placement: 'bottom-start' | 'bottom-end' | 'top-start' | 'top-end' = 'bottom-start';\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 value whenever the selection changes. */\n @Output() valueChange = new EventEmitter<any>();\n /** Emitted with the query text as the user searches. */\n @Output() searchChange = new EventEmitter<string>();\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\n readonly panelId = `combobox-panel-${Math.random().toString(36).slice(2, 11)}`;\n\n value: any = null;\n isOpen = false;\n protected cn = cn;\n\n private cleanup?: () => void;\n private onChange: (value: any) => void = () => {};\n private onTouched: () => void = () => {};\n\n constructor(private cdr: ChangeDetectorRef) {}\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 get selectedLabel(): string {\n return this.options.find(o => o.value === this.value)?.label ?? '';\n }\n\n get computedTriggerClass() {\n return cn(comboboxTriggerVariants({ size: this.size, invalid: this.invalid }), this.class);\n }\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 // Focus the search box so the user can type straight away.\n this.panelEl?.nativeElement.querySelector('input')?.focus();\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 onSelect(value: any): void {\n this.value = value;\n this.onChange(value);\n this.valueChange.emit(value);\n if (this.closeOnSelect) {\n this.close();\n this.triggerEl?.nativeElement.focus();\n }\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 placement: this.placement,\n middleware: [\n offset(4),\n flip(),\n shift({ padding: 8 }),\n // Match the panel to the trigger width and cap its height to the space\n // actually available, so the list scrolls instead of overflowing.\n size({\n padding: 8,\n apply({ rects, availableHeight, elements }) {\n Object.assign(elements.floating.style, {\n minWidth: `${rects.reference.width}px`,\n maxHeight: `${Math.max(160, availableHeight)}px`,\n });\n },\n }),\n ],\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 writeValue(value: any): void {\n this.value = value;\n this.cdr.markForCheck();\n }\n\n registerOnChange(fn: (value: any) => void): void {\n this.onChange = fn;\n }\n\n registerOnTouched(fn: () => void): void {\n this.onTouched = fn;\n }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n}\n"
19
+ },
20
+ {
21
+ "path": "command.service.ts",
22
+ "target": "ui/command.service.ts",
23
+ "content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject, Subject } from 'rxjs';\n\n/** One registered `tolle-command-item`, as seen by the service. */\nexport interface CommandItemState {\n id: string;\n /** Text the query is matched against — the item's label plus any `keywords`. */\n searchText: string;\n value: any;\n disabled: boolean;\n /** Registration order, used to keep the visible list in DOM order. */\n order: number;\n}\n\n/**\n * Coordinates a `tolle-command` and its descendants: owns the query, decides\n * which items survive filtering, and tracks the active (highlighted) item for\n * `aria-activedescendant`.\n *\n * Provided on `CommandComponent`, so each command menu gets its own instance.\n */\n@Injectable()\nexport class CommandService {\n private readonly items = new Map<string, CommandItemState>();\n private nextOrder = 0;\n\n private readonly querySubject = new BehaviorSubject<string>('');\n /** Current search query. */\n readonly query$ = this.querySubject.asObservable();\n\n private readonly visibleSubject = new BehaviorSubject<string[]>([]);\n /** Ids of the items surviving the current query, in DOM order. */\n readonly visible$ = this.visibleSubject.asObservable();\n\n private readonly activeIdSubject = new BehaviorSubject<string | null>(null);\n /** Id of the highlighted item, or null when nothing matches. */\n readonly activeId$ = this.activeIdSubject.asObservable();\n\n private readonly selectedSubject = new Subject<any>();\n /** Emits the value of the item the user chose. */\n readonly selected$ = this.selectedSubject.asObservable();\n\n /**\n * When set, filtering is left entirely to the consumer — every registered\n * item stays visible regardless of the query. Use for server-side search.\n */\n shouldFilter = true;\n\n get query(): string {\n return this.querySubject.value;\n }\n\n get visibleIds(): string[] {\n return this.visibleSubject.value;\n }\n\n get activeId(): string | null {\n return this.activeIdSubject.value;\n }\n\n /**\n * True when there is nothing to show.\n *\n * With built-in filtering that means items exist but the query excluded them\n * all. With `shouldFilter = false` the consumer renders the result set itself,\n * so \"no items registered\" IS the empty result — requiring items to exist\n * would make `tolle-command-empty` permanently invisible in that mode.\n */\n get isEmpty(): boolean {\n if (!this.shouldFilter) return this.items.size === 0;\n return this.items.size > 0 && this.visibleSubject.value.length === 0;\n }\n\n register(item: Omit<CommandItemState, 'order'>): void {\n this.items.set(item.id, { ...item, order: this.nextOrder++ });\n this.recompute();\n }\n\n /** Keeps the stored search text/value in sync when an item's inputs change. */\n update(id: string, patch: Partial<Omit<CommandItemState, 'id' | 'order'>>): void {\n const existing = this.items.get(id);\n if (!existing) return;\n this.items.set(id, { ...existing, ...patch });\n this.recompute();\n }\n\n unregister(id: string): void {\n this.items.delete(id);\n this.recompute();\n }\n\n setQuery(query: string): void {\n this.querySubject.next(query);\n this.recompute();\n }\n\n isVisible(id: string): boolean {\n return this.visibleSubject.value.includes(id);\n }\n\n /** Moves the highlight by `delta` positions, wrapping at both ends. */\n move(delta: number): void {\n const ids = this.visibleSubject.value;\n if (!ids.length) return;\n const current = ids.indexOf(this.activeIdSubject.value ?? '');\n // From \"nothing active\", ArrowUp should land on the last item, not the second.\n const start = current < 0 ? (delta > 0 ? -1 : 0) : current;\n const next = (start + delta + ids.length) % ids.length;\n this.activeIdSubject.next(ids[next]);\n }\n\n setActive(id: string | null): void {\n if (id !== null && !this.isVisible(id)) return;\n this.activeIdSubject.next(id);\n }\n\n /** Selects the highlighted item, if there is one. */\n selectActive(): void {\n const id = this.activeIdSubject.value;\n if (!id) return;\n const item = this.items.get(id);\n if (!item || item.disabled) return;\n this.selectedSubject.next(item.value);\n }\n\n selectById(id: string): void {\n const item = this.items.get(id);\n if (!item || item.disabled) return;\n this.selectedSubject.next(item.value);\n }\n\n /** Recomputes the visible set and keeps the highlight on something valid. */\n private recompute(): void {\n const q = this.querySubject.value.trim().toLowerCase();\n\n const visible = [...this.items.values()]\n .filter((item) => !this.shouldFilter || !q || item.searchText.toLowerCase().includes(q))\n .sort((a, b) => a.order - b.order)\n .map((item) => item.id);\n\n this.visibleSubject.next(visible);\n\n // If the highlight fell out of the visible set (or there was none), move it\n // to the first selectable item so Enter always has a sensible target.\n const active = this.activeIdSubject.value;\n if (!active || !visible.includes(active)) {\n const firstEnabled = visible.find((id) => !this.items.get(id)?.disabled) ?? null;\n this.activeIdSubject.next(firstEnabled);\n }\n }\n}\n"
24
+ },
25
+ {
26
+ "path": "utils/cn.ts",
27
+ "target": "ui/utils/cn.ts",
28
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
29
+ }
30
+ ]
31
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "command-dialog",
3
+ "type": "registry:ui",
4
+ "title": "Command Dialog",
5
+ "description": "Command Dialog component.",
6
+ "category": "overlays",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "command-dialog.component.ts",
12
+ "target": "ui/command-dialog.component.ts",
13
+ "content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ElementRef,\n ViewChild,\n OnInit,\n OnDestroy,\n ChangeDetectorRef,\n} from '@angular/core';\n\nimport { cn } from './utils/cn';\n\n/**\n * The ⌘K command palette: a `tolle-command` in a centred modal overlay.\n *\n * Project a full command menu inside:\n *\n * ```html\n * <tolle-command-dialog [(open)]=\"paletteOpen\">\n * <tolle-command (selected)=\"run($event)\">\n * <tolle-command-input></tolle-command-input>\n * <tolle-command-list>…</tolle-command-list>\n * </tolle-command>\n * </tolle-command-dialog>\n * ```\n *\n * Set `shortcut` to bind a global hotkey (default ⌘K / Ctrl+K).\n * @new\n */\n@Component({\n selector: 'tolle-command-dialog',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n @if (open) {\n <div\n class=\"fixed inset-0 z-[9999] flex items-start justify-center p-4 pt-[15vh]\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-label]=\"ariaLabel\">\n <div\n class=\"fixed inset-0 bg-black/50 backdrop-blur-sm animate-in fade-in\"\n aria-hidden=\"true\"\n (click)=\"onBackdropClick()\"></div>\n <div\n #panel\n [class]=\"\n cn(\n 'relative z-10 w-full max-w-lg overflow-hidden rounded-lg border border-border bg-popover shadow-lg',\n class\n )\n \">\n <ng-content></ng-content>\n </div>\n </div>\n }\n `,\n})\nexport class CommandDialogComponent implements OnInit, OnDestroy {\n /** Whether the palette is visible. Supports two-way binding via `[(open)]`. @default false */\n @Input() open = false;\n /**\n * Global hotkey that toggles the palette, as `mod+key` where `mod` maps to ⌘\n * on macOS and Ctrl elsewhere. Set to `''` to disable the binding.\n * @default 'mod+k'\n */\n @Input() shortcut = 'mod+k';\n /** Close the palette when the backdrop is clicked. @default true */\n @Input() closeOnBackdrop = true;\n /** Accessible name for the dialog. @default 'Command palette' */\n @Input() ariaLabel = 'Command palette';\n /** Extra Tailwind classes merged onto the panel via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Two-way binding partner for `open`. */\n @Output() openChange = new EventEmitter<boolean>();\n /** Emitted when the palette opens. */\n @Output() opened = new EventEmitter<void>();\n /** Emitted when the palette closes. */\n @Output() closed = new EventEmitter<void>();\n\n @ViewChild('panel') panelEl?: ElementRef<HTMLElement>;\n\n protected cn = cn;\n\n constructor(private cdr: ChangeDetectorRef) {}\n\n private readonly onKeyDown = (event: KeyboardEvent) => {\n if (event.key === 'Escape' && this.open) {\n event.preventDefault();\n this.setOpen(false);\n return;\n }\n\n if (!this.shortcut) return;\n const parts = this.shortcut.toLowerCase().split('+');\n const key = parts[parts.length - 1];\n const needsMod = parts.includes('mod') || parts.includes('meta') || parts.includes('ctrl');\n const modHeld = event.metaKey || event.ctrlKey;\n\n if (event.key.toLowerCase() === key && (!needsMod || modHeld)) {\n event.preventDefault();\n this.setOpen(!this.open);\n }\n };\n\n ngOnInit(): void {\n document.addEventListener('keydown', this.onKeyDown);\n }\n\n ngOnDestroy(): void {\n document.removeEventListener('keydown', this.onKeyDown);\n }\n\n onBackdropClick(): void {\n if (this.closeOnBackdrop) this.setOpen(false);\n }\n\n private setOpen(next: boolean): void {\n if (this.open === next) return;\n this.open = next;\n this.openChange.emit(next);\n (next ? this.opened : this.closed).emit();\n this.cdr.markForCheck();\n\n if (next) {\n // Focus the projected search box once the panel is in the DOM, so typing\n // works immediately on open.\n setTimeout(() => this.panelEl?.nativeElement.querySelector('input')?.focus());\n }\n }\n}\n"
14
+ },
15
+ {
16
+ "path": "utils/cn.ts",
17
+ "target": "ui/utils/cn.ts",
18
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
19
+ }
20
+ ]
21
+ }
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "command",
3
+ "type": "registry:ui",
4
+ "title": "Command",
5
+ "description": "Command component.",
6
+ "category": "overlays",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "command.component.ts",
12
+ "target": "ui/command.component.ts",
13
+ "content": "import {\n Component,\n Input,\n Output,\n EventEmitter,\n ChangeDetectionStrategy,\n ChangeDetectorRef,\n OnInit,\n OnChanges,\n OnDestroy,\n inject,\n} from '@angular/core';\n\nimport { Subscription } from 'rxjs';\nimport { cn } from './utils/cn';\nimport { CommandService } from './command.service';\n\nlet commandItemId = 0;\n\n/**\n * A filterable command menu — the list half of a command palette (⌘K),\n * a searchable dropdown, or an inline action list.\n *\n * Compose it from `tolle-command-input`, `tolle-command-list`,\n * `tolle-command-group`, `tolle-command-item` and `tolle-command-empty`.\n * Filtering, keyboard navigation and `aria-activedescendant` are handled for\n * you by the `CommandService` this component provides.\n *\n * For the ⌘K modal version use `tolle-command-dialog`; for a form control use\n * `tolle-combobox`.\n * @new\n */\n@Component({\n selector: 'tolle-command',\n styles: [':host { display: block; }'],\n imports: [],\n providers: [CommandService],\n template: `\n <div\n role=\"combobox\"\n aria-haspopup=\"listbox\"\n aria-expanded=\"true\"\n [attr.aria-label]=\"ariaLabel || null\"\n [class]=\"computedClass\"\n (keydown)=\"onKeyDown($event)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class CommandComponent implements OnInit, OnChanges, OnDestroy {\n /**\n * Set false to keep every item visible and filter the list yourself —\n * for server-side or fuzzy search. @default true\n */\n @Input() shouldFilter = true;\n /** Accessible name for the command menu. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the menu via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted with the chosen item's `value` when the user selects one. */\n @Output() selected = new EventEmitter<any>();\n /** Emitted with the query text whenever the search input changes. */\n @Output() queryChange = new EventEmitter<string>();\n\n private readonly service = inject(CommandService);\n private readonly subscriptions = new Subscription();\n\n ngOnInit(): void {\n this.service.shouldFilter = this.shouldFilter;\n this.subscriptions.add(this.service.selected$.subscribe(value => this.selected.emit(value)));\n this.subscriptions.add(this.service.query$.subscribe(query => this.queryChange.emit(query)));\n }\n\n ngOnChanges(): void {\n this.service.shouldFilter = this.shouldFilter;\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n get computedClass() {\n return cn(\n 'flex w-full flex-col overflow-hidden rounded-md bg-popover text-popover-foreground',\n this.class\n );\n }\n\n /**\n * Menu-level keyboard handling, so the arrows work whether focus is in the\n * search input or on the menu itself.\n */\n onKeyDown(event: KeyboardEvent): void {\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n this.service.move(1);\n break;\n case 'ArrowUp':\n event.preventDefault();\n this.service.move(-1);\n break;\n case 'Home':\n event.preventDefault();\n this.service.setActive(this.service.visibleIds[0] ?? null);\n break;\n case 'End':\n event.preventDefault();\n this.service.setActive(this.service.visibleIds[this.service.visibleIds.length - 1] ?? null);\n break;\n case 'Enter':\n event.preventDefault();\n this.service.selectActive();\n break;\n }\n }\n}\n\n/** Search box for a `tolle-command`. Typing here drives the filtering. */\n@Component({\n selector: 'tolle-command-input',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n <div [class]=\"cn('flex items-center gap-2 border-b border-border px-3', class)\">\n <i class=\"ri-search-line shrink-0 text-muted-foreground\" aria-hidden=\"true\"></i>\n <input\n [attr.placeholder]=\"placeholder\"\n [attr.aria-activedescendant]=\"activeId\"\n [attr.aria-label]=\"ariaLabel || placeholder || 'Search'\"\n aria-autocomplete=\"list\"\n autocomplete=\"off\"\n spellcheck=\"false\"\n [value]=\"value\"\n [class]=\"\n cn(\n 'h-11 w-full bg-transparent py-3 text-sm text-foreground outline-none',\n 'placeholder:text-muted-foreground disabled:cursor-not-allowed disabled:opacity-50',\n inputClass\n )\n \"\n (input)=\"onInput($event)\" />\n </div>\n `,\n})\nexport class CommandInputComponent implements OnInit, OnDestroy {\n /** Placeholder shown when the query is empty. @default 'Type a command or search…' */\n @Input() placeholder = 'Type a command or search…';\n /** Accessible name for the search box. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the search row via `cn()` (last-wins). */\n @Input() class = '';\n /** Extra Tailwind classes merged onto the `<input>` itself via `cn()` (last-wins). */\n @Input() inputClass = '';\n\n private readonly service = inject(CommandService);\n private readonly subscriptions = new Subscription();\n private readonly cdr = inject(ChangeDetectorRef);\n\n value = '';\n activeId: string | null = null;\n protected cn = cn;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.activeId$.subscribe(id => {\n this.activeId = id;\n this.cdr.markForCheck();\n })\n );\n // The dialog clears the query when it reopens; mirror that back into the box.\n this.subscriptions.add(\n this.service.query$.subscribe(query => {\n if (query !== this.value) {\n this.value = query;\n this.cdr.markForCheck();\n }\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n onInput(event: Event): void {\n const next = (event.target as HTMLInputElement).value;\n this.value = next;\n this.service.setQuery(next);\n }\n}\n\n/** Scrolling container for the command items. */\n@Component({\n selector: 'tolle-command-list',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n role=\"listbox\"\n [attr.aria-label]=\"ariaLabel || null\"\n [class]=\"cn('max-h-80 overflow-y-auto overflow-x-hidden p-1', class)\">\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class CommandListComponent 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 /** Accessible name for the list. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the list via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n\n/**\n * Shown only when the query matches nothing. Renders nothing otherwise, so it\n * can live in the template permanently.\n */\n@Component({\n selector: 'tolle-command-empty',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n @if (isEmpty) {\n <div role=\"status\" [class]=\"cn('py-6 text-center text-sm text-muted-foreground', class)\">\n <ng-content></ng-content>\n </div>\n }\n `,\n})\nexport class CommandEmptyComponent implements OnInit, OnDestroy {\n /** Extra Tailwind classes merged onto the empty state via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(CommandService);\n private readonly subscriptions = new Subscription();\n private readonly cdr = inject(ChangeDetectorRef);\n\n isEmpty = false;\n protected cn = cn;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.visible$.subscribe(() => {\n this.isEmpty = this.service.isEmpty;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n}\n\n/**\n * A labelled section of items. The heading hides itself when filtering removes\n * every item in the group, so you never get a stranded header.\n */\n@Component({\n selector: 'tolle-command-group',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n <div\n role=\"group\"\n [attr.aria-label]=\"heading || null\"\n [class]=\"cn('overflow-hidden p-1 text-foreground', class)\">\n @if (heading) {\n <div\n [class]=\"cn('px-2 py-1.5 text-xs font-medium text-muted-foreground', hidden && 'hidden')\">\n {{ heading }}\n </div>\n }\n <ng-content></ng-content>\n </div>\n `,\n})\nexport class CommandGroupComponent implements OnInit, OnDestroy {\n /** Section heading. @default '' */\n @Input() heading = '';\n /** Extra Tailwind classes merged onto the group via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly service = inject(CommandService);\n private readonly subscriptions = new Subscription();\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly memberIds = new Set<string>();\n\n hidden = false;\n protected cn = cn;\n\n ngOnInit(): void {\n this.subscriptions.add(\n this.service.visible$.subscribe(visible => {\n // Hide the heading once none of this group's items survive the filter.\n this.hidden = this.memberIds.size > 0 && !visible.some(id => this.memberIds.has(id));\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy(): void {\n this.subscriptions.unsubscribe();\n }\n\n /** Called by child items so the group knows which ids belong to it. */\n addMember(id: string): void {\n this.memberIds.add(id);\n }\n\n removeMember(id: string): void {\n this.memberIds.delete(id);\n }\n}\n\n/** A selectable row in a command menu. */\n@Component({\n selector: 'tolle-command-item',\n styles: [':host { display: block; }'],\n imports: [],\n template: `\n @if (visible) {\n <div\n [id]=\"id\"\n role=\"option\"\n [attr.aria-selected]=\"active\"\n [attr.aria-disabled]=\"disabled || null\"\n [attr.data-active]=\"active ? '' : null\"\n [class]=\"computedClass\"\n (click)=\"onClick()\"\n (mouseenter)=\"onMouseEnter()\">\n <ng-content></ng-content>\n </div>\n }\n `,\n})\nexport class CommandItemComponent implements OnInit, OnChanges, OnDestroy {\n /** Value emitted from the menu's `selected` output when this row is chosen. */\n @Input() value: any = null;\n /**\n * Text matched against the query. Defaults to `value` when omitted — set it\n * explicitly whenever the row's visible text differs from its value.\n * @default ''\n */\n @Input() label = '';\n /** Extra terms that should also match this row, e.g. synonyms. @default [] */\n @Input() keywords: string[] = [];\n /** Makes the row unselectable and skipped by the arrow keys. @default false */\n @Input() disabled = false;\n /** Extra Tailwind classes merged onto the row via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when this specific row is chosen. */\n @Output() selected = new EventEmitter<any>();\n\n private readonly service = inject(CommandService);\n private readonly group = inject(CommandGroupComponent, { optional: true });\n private readonly subscriptions = new Subscription();\n private readonly cdr = inject(ChangeDetectorRef);\n\n readonly id = `command-item-${commandItemId++}`;\n visible = true;\n active = false;\n\n ngOnInit(): void {\n this.service.register({\n id: this.id,\n searchText: this.searchText,\n value: this.value,\n disabled: this.disabled,\n });\n this.group?.addMember(this.id);\n\n this.subscriptions.add(\n this.service.visible$.subscribe(ids => {\n this.visible = ids.includes(this.id);\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.activeId$.subscribe(activeId => {\n this.active = activeId === this.id;\n this.cdr.markForCheck();\n })\n );\n this.subscriptions.add(\n this.service.selected$.subscribe(value => {\n if (value === this.value) this.selected.emit(value);\n })\n );\n }\n\n ngOnChanges(): void {\n this.service.update(this.id, {\n searchText: this.searchText,\n value: this.value,\n disabled: this.disabled,\n });\n }\n\n ngOnDestroy(): void {\n this.group?.removeMember(this.id);\n this.service.unregister(this.id);\n this.subscriptions.unsubscribe();\n }\n\n /** Label plus keywords, so synonyms match without being displayed. */\n private get searchText(): string {\n const base = this.label || (this.value != null ? String(this.value) : '');\n return [base, ...this.keywords].join(' ');\n }\n\n get computedClass() {\n return cn(\n 'relative flex cursor-pointer select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors',\n this.active && !this.disabled && 'bg-accent text-accent-foreground',\n this.disabled && 'pointer-events-none opacity-50',\n this.class\n );\n }\n\n onClick(): void {\n if (this.disabled) return;\n this.service.selectById(this.id);\n }\n\n onMouseEnter(): void {\n if (this.disabled) return;\n this.service.setActive(this.id);\n }\n}\n\n/** Divider between command groups. */\n@Component({\n selector: 'tolle-command-separator',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<div role=\"separator\" [class]=\"cn('-mx-1 h-px bg-border', class)\"></div>`,\n})\nexport class CommandSeparatorComponent 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/** Right-aligned shortcut hint inside a command item. Pairs with `tolle-kbd`. */\n@Component({\n selector: 'tolle-command-shortcut',\n styles: [':host { display: inline-flex; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `<span\n [class]=\"\n cn('ml-auto flex items-center gap-1 text-xs tracking-widest text-muted-foreground', class)\n \"\n ><ng-content></ng-content\n ></span>`,\n})\nexport class CommandShortcutComponent 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 shortcut via `cn()` (last-wins). */\n @Input() class = '';\n protected cn = cn;\n}\n"
14
+ },
15
+ {
16
+ "path": "command.service.ts",
17
+ "target": "ui/command.service.ts",
18
+ "content": "import { Injectable } from '@angular/core';\nimport { BehaviorSubject, Subject } from 'rxjs';\n\n/** One registered `tolle-command-item`, as seen by the service. */\nexport interface CommandItemState {\n id: string;\n /** Text the query is matched against — the item's label plus any `keywords`. */\n searchText: string;\n value: any;\n disabled: boolean;\n /** Registration order, used to keep the visible list in DOM order. */\n order: number;\n}\n\n/**\n * Coordinates a `tolle-command` and its descendants: owns the query, decides\n * which items survive filtering, and tracks the active (highlighted) item for\n * `aria-activedescendant`.\n *\n * Provided on `CommandComponent`, so each command menu gets its own instance.\n */\n@Injectable()\nexport class CommandService {\n private readonly items = new Map<string, CommandItemState>();\n private nextOrder = 0;\n\n private readonly querySubject = new BehaviorSubject<string>('');\n /** Current search query. */\n readonly query$ = this.querySubject.asObservable();\n\n private readonly visibleSubject = new BehaviorSubject<string[]>([]);\n /** Ids of the items surviving the current query, in DOM order. */\n readonly visible$ = this.visibleSubject.asObservable();\n\n private readonly activeIdSubject = new BehaviorSubject<string | null>(null);\n /** Id of the highlighted item, or null when nothing matches. */\n readonly activeId$ = this.activeIdSubject.asObservable();\n\n private readonly selectedSubject = new Subject<any>();\n /** Emits the value of the item the user chose. */\n readonly selected$ = this.selectedSubject.asObservable();\n\n /**\n * When set, filtering is left entirely to the consumer — every registered\n * item stays visible regardless of the query. Use for server-side search.\n */\n shouldFilter = true;\n\n get query(): string {\n return this.querySubject.value;\n }\n\n get visibleIds(): string[] {\n return this.visibleSubject.value;\n }\n\n get activeId(): string | null {\n return this.activeIdSubject.value;\n }\n\n /**\n * True when there is nothing to show.\n *\n * With built-in filtering that means items exist but the query excluded them\n * all. With `shouldFilter = false` the consumer renders the result set itself,\n * so \"no items registered\" IS the empty result — requiring items to exist\n * would make `tolle-command-empty` permanently invisible in that mode.\n */\n get isEmpty(): boolean {\n if (!this.shouldFilter) return this.items.size === 0;\n return this.items.size > 0 && this.visibleSubject.value.length === 0;\n }\n\n register(item: Omit<CommandItemState, 'order'>): void {\n this.items.set(item.id, { ...item, order: this.nextOrder++ });\n this.recompute();\n }\n\n /** Keeps the stored search text/value in sync when an item's inputs change. */\n update(id: string, patch: Partial<Omit<CommandItemState, 'id' | 'order'>>): void {\n const existing = this.items.get(id);\n if (!existing) return;\n this.items.set(id, { ...existing, ...patch });\n this.recompute();\n }\n\n unregister(id: string): void {\n this.items.delete(id);\n this.recompute();\n }\n\n setQuery(query: string): void {\n this.querySubject.next(query);\n this.recompute();\n }\n\n isVisible(id: string): boolean {\n return this.visibleSubject.value.includes(id);\n }\n\n /** Moves the highlight by `delta` positions, wrapping at both ends. */\n move(delta: number): void {\n const ids = this.visibleSubject.value;\n if (!ids.length) return;\n const current = ids.indexOf(this.activeIdSubject.value ?? '');\n // From \"nothing active\", ArrowUp should land on the last item, not the second.\n const start = current < 0 ? (delta > 0 ? -1 : 0) : current;\n const next = (start + delta + ids.length) % ids.length;\n this.activeIdSubject.next(ids[next]);\n }\n\n setActive(id: string | null): void {\n if (id !== null && !this.isVisible(id)) return;\n this.activeIdSubject.next(id);\n }\n\n /** Selects the highlighted item, if there is one. */\n selectActive(): void {\n const id = this.activeIdSubject.value;\n if (!id) return;\n const item = this.items.get(id);\n if (!item || item.disabled) return;\n this.selectedSubject.next(item.value);\n }\n\n selectById(id: string): void {\n const item = this.items.get(id);\n if (!item || item.disabled) return;\n this.selectedSubject.next(item.value);\n }\n\n /** Recomputes the visible set and keeps the highlight on something valid. */\n private recompute(): void {\n const q = this.querySubject.value.trim().toLowerCase();\n\n const visible = [...this.items.values()]\n .filter((item) => !this.shouldFilter || !q || item.searchText.toLowerCase().includes(q))\n .sort((a, b) => a.order - b.order)\n .map((item) => item.id);\n\n this.visibleSubject.next(visible);\n\n // If the highlight fell out of the visible set (or there was none), move it\n // to the first selectable item so Enter always has a sensible target.\n const active = this.activeIdSubject.value;\n if (!active || !visible.includes(active)) {\n const firstEnabled = visible.find((id) => !this.items.get(id)?.disabled) ?? null;\n this.activeIdSubject.next(firstEnabled);\n }\n }\n}\n"
19
+ },
20
+ {
21
+ "path": "utils/cn.ts",
22
+ "target": "ui/utils/cn.ts",
23
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
24
+ }
25
+ ]
26
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "confirmation",
3
+ "type": "registry:ui",
4
+ "title": "Confirmation",
5
+ "description": "Confirmation component.",
6
+ "category": "ai",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "class-variance-authority"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "confirmation.component.ts",
14
+ "target": "ui/confirmation.component.ts",
15
+ "content": "import {\n ChangeDetectionStrategy,\n Component,\n EventEmitter,\n Input,\n OnChanges,\n Output,\n SimpleChanges,\n ChangeDetectorRef,\n inject,\n} from '@angular/core';\n\nimport { cva, type VariantProps } from 'class-variance-authority';\nimport { cn } from './utils/cn';\n\n/** Whether the user has answered the prompt yet, and how. */\nexport type ConfirmationState = 'pending' | 'confirmed' | 'cancelled';\n\nconst confirmationVariants = cva('w-full rounded-lg border p-3 text-sm transition-colors', {\n variants: {\n state: {\n pending: 'border-border bg-card text-card-foreground',\n confirmed: 'border-success/40 bg-success/5 text-card-foreground',\n cancelled: 'border-destructive/40 bg-destructive/5 text-card-foreground',\n },\n },\n defaultVariants: { state: 'pending' },\n});\n\nconst confirmationIconVariants = cva('mt-0.5 shrink-0 text-base leading-none', {\n variants: {\n state: {\n pending: 'text-warning',\n confirmed: 'text-success',\n cancelled: 'text-destructive',\n },\n },\n defaultVariants: { state: 'pending' },\n});\n\nconst confirmationResolvedVariants = cva(\n 'inline-flex shrink-0 items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium',\n {\n variants: {\n state: {\n pending: 'border-border text-muted-foreground',\n confirmed: 'border-success/40 bg-success/10 text-success',\n cancelled: 'border-destructive/40 bg-destructive/10 text-destructive',\n },\n },\n defaultVariants: { state: 'pending' },\n }\n);\n\nexport type ConfirmationProps = VariantProps<typeof confirmationVariants>;\n\n/** Remixicon class shown beside the prompt for each state. */\nconst CONFIRMATION_ICONS: Record<ConfirmationState, string> = {\n pending: 'ri-shield-check-line',\n confirmed: 'ri-checkbox-circle-fill',\n cancelled: 'ri-close-circle-fill',\n};\n\n/**\n * An inline approve / deny prompt for an agent action that needs a human to\n * sign off. While `state` is `'pending'` it shows both buttons; once the user\n * answers — or the host sets `state` — the buttons collapse into a compact\n * resolved row.\n *\n * ```html\n * <tolle-confirmation\n * title=\"Delete 12 rows from the orders table?\"\n * description=\"This cannot be undone.\"\n * (confirmed)=\"run()\"\n * (cancelled)=\"skip()\"\n * ></tolle-confirmation>\n * ```\n * @new\n */\n@Component({\n selector: 'tolle-confirmation',\n styles: [':host { display: block; }'],\n imports: [],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n [class]=\"computedClass\"\n [attr.data-state]=\"currentState\"\n role=\"group\"\n [attr.aria-label]=\"title\">\n <div class=\"flex items-start gap-3\">\n <i [class]=\"iconClass\" aria-hidden=\"true\"></i>\n\n <div class=\"min-w-0 flex-1\">\n <p class=\"font-medium text-foreground\">{{ title }}</p>\n @if (description) {\n <p class=\"mt-0.5 text-sm text-muted-foreground\">{{ description }}</p>\n }\n\n <div class=\"empty:hidden\"><ng-content></ng-content></div>\n\n @if (currentState === 'pending') {\n <div class=\"mt-3 flex flex-wrap items-center gap-2\">\n <button\n type=\"button\"\n [disabled]=\"disabled\"\n [class]=\"confirmButtonClass\"\n (click)=\"confirm()\">\n <i class=\"ri-check-line\" aria-hidden=\"true\"></i>\n {{ confirmLabel }}\n </button>\n <button\n type=\"button\"\n [disabled]=\"disabled\"\n [class]=\"cancelButtonClass\"\n (click)=\"cancel()\">\n <i class=\"ri-close-line\" aria-hidden=\"true\"></i>\n {{ cancelLabel }}\n </button>\n </div>\n }\n </div>\n\n @if (currentState !== 'pending') {\n <span [class]=\"resolvedClass\">\n <i [class]=\"iconName\" aria-hidden=\"true\"></i>\n {{ resolvedLabel }}\n </span>\n }\n </div>\n </div>\n `,\n})\nexport class ConfirmationComponent implements OnChanges {\n private readonly cdr = inject(ChangeDetectorRef);\n\n /** Headline describing the action awaiting approval. @default '' */\n @Input() title = '';\n /** Supporting detail shown under the title. @default '' */\n @Input() description = '';\n /** Label of the approve button. @default 'Approve' */\n @Input() confirmLabel = 'Approve';\n /** Label of the deny button. @default 'Deny' */\n @Input() cancelLabel = 'Deny';\n /** Text shown in the resolved row after approval. @default 'Approved' */\n @Input() confirmedLabel = 'Approved';\n /** Text shown in the resolved row after denial. @default 'Denied' */\n @Input() cancelledLabel = 'Denied';\n /** Whether the prompt is still open, and how it was answered. @default 'pending' */\n @Input() state: ConfirmationState = 'pending';\n /** Blocks both buttons without resolving the prompt. @default false */\n @Input() disabled = false;\n /** Extra Tailwind classes merged onto the prompt via `cn()` (last-wins). */\n @Input() class = '';\n\n /** Emitted when the user approves the action. */\n @Output() confirmed = new EventEmitter<void>();\n /** Emitted when the user denies the action. */\n @Output() cancelled = new EventEmitter<void>();\n\n /**\n * Set when the user answers, so the prompt resolves on its own without the\n * host having to bind `state` back. Cleared whenever `state` is pushed in.\n */\n private answer: ConfirmationState | null = null;\n\n ngOnChanges(changes: SimpleChanges): void {\n if (changes['state']) this.answer = null;\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 /** The state actually rendered: the user's answer if there is one, else `state`. */\n get currentState(): ConfirmationState {\n return this.answer ?? this.state;\n }\n\n get resolvedLabel(): string {\n return this.currentState === 'confirmed' ? this.confirmedLabel : this.cancelledLabel;\n }\n\n get iconName(): string {\n return CONFIRMATION_ICONS[this.currentState];\n }\n\n get computedClass() {\n return cn(confirmationVariants({ state: this.currentState }), this.class);\n }\n\n get iconClass() {\n return cn(confirmationIconVariants({ state: this.currentState }), this.iconName);\n }\n\n get resolvedClass() {\n return cn(confirmationResolvedVariants({ state: this.currentState }));\n }\n\n get confirmButtonClass() {\n return cn(\n 'inline-flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-primary-foreground transition-colors',\n 'hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50'\n );\n }\n\n get cancelButtonClass() {\n return cn(\n 'inline-flex h-8 items-center gap-1.5 rounded-md border border-border bg-background px-3 text-xs font-medium text-foreground transition-colors',\n 'hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50'\n );\n }\n\n confirm(): void {\n if (this.disabled || this.currentState !== 'pending') return;\n this.answer = 'confirmed';\n this.confirmed.emit();\n }\n\n cancel(): void {\n if (this.disabled || this.currentState !== 'pending') return;\n this.answer = 'cancelled';\n this.cancelled.emit();\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "utils/cn.ts",
19
+ "target": "ui/utils/cn.ts",
20
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,23 @@
1
+ {
2
+ "name": "context-menu-trigger",
3
+ "type": "registry:ui",
4
+ "title": "Context Menu Trigger",
5
+ "description": "Context Menu Trigger component.",
6
+ "category": "overlays",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "@floating-ui/dom"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "context-menu-trigger.directive.ts",
14
+ "target": "ui/context-menu-trigger.directive.ts",
15
+ "content": "import { Directive, ElementRef, EventEmitter, HostListener, inject, Input, Output } from '@angular/core';\nimport { ContextMenuService, ContextMenuItem } from './context-menu.service';\n\n@Directive({\n selector: '[tolleContextMenuTrigger]',\n standalone: true\n})\nexport class ContextMenuTriggerDirective {\n private elementRef = inject(ElementRef);\n private contextMenuService = inject(ContextMenuService);\n\n @Input('tolleContextMenuTrigger') items: ContextMenuItem[] = [];\n @Output() action = new EventEmitter<string>();\n\n @HostListener('contextmenu', ['$event'])\n onContextMenu(event: MouseEvent) {\n event.stopPropagation();\n this.contextMenuService.open({\n event,\n items: this.items,\n triggerElement: this.elementRef.nativeElement,\n onAction: (actionId) => this.action.emit(actionId)\n });\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "context-menu.service.ts",
19
+ "target": "ui/context-menu.service.ts",
20
+ "content": "import { Injectable, EventEmitter } from '@angular/core';\nimport { computePosition, offset, shift, flip, size } from '@floating-ui/dom';\n\nexport type ContextMenuItem = {\n id?: string;\n label?: string;\n icon?: string;\n disabled?: boolean;\n destructive?: boolean;\n separator?: boolean;\n submenu?: ContextMenuItem[];\n shortcut?: string;\n checked?: boolean;\n type?: 'default' | 'checkbox' | 'radio';\n}\n\nexport type ContextMenuState = {\n x: number;\n y: number;\n items: ContextMenuItem[];\n isOpen: boolean;\n triggerElement?: HTMLElement;\n onAction?: (actionId: string) => void;\n}\n\n@Injectable({\n providedIn: 'root'\n})\nexport class ContextMenuService {\n private _state: ContextMenuState = {\n x: 0,\n y: 0,\n items: [],\n isOpen: false\n };\n\n readonly stateChanged = new EventEmitter<ContextMenuState>();\n\n open(config: {\n event: MouseEvent;\n items?: ContextMenuItem[];\n triggerElement?: HTMLElement;\n onAction?: (actionId: string) => void;\n }) {\n const { event, items = [], triggerElement, onAction } = config;\n\n // Prevent browser context menu\n event.preventDefault();\n\n this._state = {\n x: event.clientX,\n y: event.clientY,\n items,\n isOpen: true,\n triggerElement,\n onAction\n };\n\n this.stateChanged.emit(this._state);\n }\n\n close() {\n this._state = { ...this._state, isOpen: false };\n this.stateChanged.emit(this._state);\n }\n\n async positionMenu(menuElement: HTMLElement) {\n const state = this._state;\n if (!state.isOpen || !menuElement) return;\n\n // Create a precise virtual element for the mouse coordinates\n const virtualEl = {\n getBoundingClientRect() {\n return {\n width: 0,\n height: 0,\n x: state.x,\n y: state.y,\n top: state.y,\n left: state.x,\n right: state.x,\n bottom: state.y,\n };\n }\n };\n\n const { x, y } = await computePosition(virtualEl as any, menuElement, {\n placement: 'bottom-start', // Standard: Menu top-left aligns with mouse\n strategy: 'fixed',\n middleware: [\n offset(2), // Slight offset so mouse isn't covering the first pixel\n flip({\n padding: 10,\n fallbackPlacements: ['bottom-end', 'top-start', 'top-end']\n }),\n shift({ padding: 10 }),\n size({\n apply({ availableHeight, availableWidth, elements }) {\n Object.assign(elements.floating.style, {\n maxHeight: `${Math.max(100, availableHeight - 16)}px`,\n maxWidth: `${Math.max(200, availableWidth - 16)}px`,\n overflowY: 'auto'\n });\n }\n })\n ]\n });\n\n // Position via left/top, not transform: the menu's own\n // data-[state=open]:zoom-in-95/slide-in-from-top-2 enter animation also\n // drives `transform`, and a CSS animation overrides whatever an inline\n // transform says for as long as it's running — so a translate() here\n // would get clobbered by the animation and only \"snap\" into place once\n // it finishes, which is exactly the top-left-then-jump flash this avoids.\n Object.assign(menuElement.style, {\n position: 'fixed',\n left: `${Math.round(x)}px`,\n top: `${Math.round(y)}px`,\n });\n }\n\n async positionSubmenu(triggerElement: HTMLElement, submenuElement: HTMLElement) {\n if (!triggerElement || !submenuElement) return;\n\n const { x, y } = await computePosition(triggerElement, submenuElement, {\n placement: 'right-start', // Standard: Submenu opens to the right\n strategy: 'fixed',\n middleware: [\n offset(-4), // Overlap slightly with parent menu\n flip({\n padding: 10,\n fallbackPlacements: ['left-start', 'bottom-start']\n }),\n shift({ padding: 10 }),\n size({\n apply({ availableHeight, availableWidth, elements }) {\n Object.assign(elements.floating.style, {\n maxHeight: `${Math.max(100, availableHeight - 16)}px`,\n maxWidth: `${Math.max(200, availableWidth - 16)}px`,\n overflowY: 'auto'\n });\n }\n })\n ]\n });\n\n Object.assign(submenuElement.style, {\n position: 'fixed',\n left: `${Math.round(x)}px`,\n top: `${Math.round(y)}px`,\n });\n }\n\n performAction(actionId: string) {\n this._state.onAction?.(actionId);\n this.close();\n }\n}"
21
+ }
22
+ ]
23
+ }
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "context-menu",
3
+ "type": "registry:ui",
4
+ "title": "Context Menu",
5
+ "description": "Context Menu component.",
6
+ "category": "overlays",
7
+ "registryDependencies": [],
8
+ "dependencies": [
9
+ "@floating-ui/dom"
10
+ ],
11
+ "files": [
12
+ {
13
+ "path": "context-menu.component.ts",
14
+ "target": "ui/context-menu.component.ts",
15
+ "content": "import {\n Component,\n ElementRef,\n HostListener,\n inject,\n OnDestroy,\n OnInit,\n ViewChild,\n ViewChildren,\n QueryList,\n ChangeDetectorRef,\n} from '@angular/core';\n\nimport { ContextMenuItem, ContextMenuService, ContextMenuState } from './context-menu.service';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-context-menu',\n imports: [],\n template: `\n @if (isOpen) {\n <div\n #menu\n role=\"menu\"\n aria-orientation=\"vertical\"\n tabindex=\"-1\"\n [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n class=\"fixed z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md slide-in-from-top-2 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95\"\n [style.visibility]=\"isPositioned ? 'visible' : 'hidden'\"\n [style.opacity]=\"isPositioned ? '1' : '0'\"\n style=\"will-change: transform; top: 0; left: 0;\"\n (keydown)=\"onKeyDown($event)\">\n <div class=\"flex flex-col\">\n @for (item of items; track $index) {\n @if (item.separator) {\n <div role=\"separator\" class=\"-mx-1 my-1 h-px bg-border\"></div>\n } @else {\n <div class=\"group relative\">\n <button\n #itemButton\n type=\"button\"\n role=\"menuitem\"\n [attr.aria-haspopup]=\"item.submenu ? 'true' : null\"\n [attr.aria-expanded]=\"openSubmenuId === item.id ? 'true' : null\"\n [attr.aria-disabled]=\"item.disabled ? 'true' : null\"\n (click)=\"onItemClick(item)\"\n (mouseenter)=\"onItemMouseEnter(item, itemButton)\"\n (mouseleave)=\"onItemMouseLeave()\"\n [disabled]=\"item.disabled\"\n [class]=\"\n cn(\n 'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',\n 'transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground',\n 'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',\n item.destructive\n ? 'text-destructive focus:bg-destructive/10 focus:text-destructive'\n : '',\n item.disabled && 'pointer-events-none cursor-not-allowed opacity-50',\n item.type === 'checkbox' || item.type === 'radio' ? 'pl-8' : '',\n activeIndex === $index && 'bg-accent text-accent-foreground'\n )\n \">\n @if (item.checked) {\n <span class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <i\n [class]=\"\n item.type === 'radio'\n ? 'ri-checkbox-blank-circle-fill h-2 w-2 fill-current'\n : 'ri-check-line h-4 w-4'\n \"></i>\n </span>\n }\n @if (item.icon) {\n <i [class]=\"cn(item.icon, 'mr-2 h-4 w-4')\"></i>\n }\n <span class=\"flex-1 text-left\">{{ item.label }}</span>\n @if (item.shortcut && !item.submenu) {\n <span class=\"ml-auto pl-2 text-xs tracking-widest text-muted-foreground\">{{\n item.shortcut\n }}</span>\n }\n @if (item.submenu) {\n <i class=\"ri-arrow-right-s-line ml-auto h-4 w-4 opacity-50\"></i>\n }\n </button>\n </div>\n }\n }\n </div>\n </div>\n\n @if (openSubmenuId && activeSubmenuItems.length > 0) {\n <div\n #submenu\n role=\"menu\"\n [id]=\"'submenu-' + openSubmenuId\"\n class=\"fixed z-50 min-w-[12rem] overflow-hidden rounded-md border border-border bg-popover p-1 text-popover-foreground shadow-md\"\n [style.visibility]=\"isSubmenuPositioned ? 'visible' : 'hidden'\"\n [style.opacity]=\"isSubmenuPositioned ? '1' : '0'\"\n style=\"will-change: transform\"\n (mouseenter)=\"onSubmenuMouseEnter()\"\n (mouseleave)=\"onSubmenuMouseLeave()\">\n @for (subItem of activeSubmenuItems; track $index) {\n @if (subItem.separator) {\n <div role=\"separator\" class=\"-mx-1 my-1 h-px bg-border\"></div>\n } @else {\n <button\n type=\"button\"\n role=\"menuitem\"\n (click)=\"onSubItemClick(subItem)\"\n [disabled]=\"subItem.disabled\"\n [class]=\"\n cn(\n 'relative flex w-full cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none',\n 'transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground',\n subItem.disabled && 'pointer-events-none cursor-not-allowed opacity-50',\n subItem.type === 'checkbox' || subItem.type === 'radio' ? 'pl-8' : '',\n subItem.destructive ? 'text-destructive hover:bg-destructive/10' : ''\n )\n \">\n @if (subItem.checked) {\n <span class=\"absolute left-2 flex h-3.5 w-3.5 items-center justify-center\">\n <i\n [class]=\"\n subItem.type === 'radio'\n ? 'ri-checkbox-blank-circle-fill h-2 w-2 fill-current'\n : 'ri-check-line h-4 w-4'\n \"></i>\n </span>\n }\n @if (subItem.icon) {\n <i [class]=\"cn(subItem.icon, 'mr-2 h-4 w-4')\"></i>\n }\n <span class=\"flex-1 text-left\">{{ subItem.label }}</span>\n @if (subItem.shortcut) {\n <span class=\"ml-auto pl-2 text-xs tracking-widest text-muted-foreground\">{{\n subItem.shortcut\n }}</span>\n }\n </button>\n }\n }\n </div>\n }\n }\n `,\n styles: [\n `\n :host {\n display: contents;\n }\n `,\n ],\n})\nexport class ContextMenuComponent implements OnInit, OnDestroy {\n private contextMenuService = inject(ContextMenuService);\n private elementRef = inject(ElementRef);\n private cdr = inject(ChangeDetectorRef);\n\n @ViewChild('menu') menuElement!: ElementRef<HTMLDivElement>;\n\n // We only have one active submenu now, so we can use ViewChild instead of ViewChildren\n @ViewChild('submenu') submenuElement!: ElementRef<HTMLDivElement>;\n\n @ViewChildren('itemButton') itemButtons!: QueryList<ElementRef<HTMLButtonElement>>;\n\n isOpen = false;\n isPositioned = false;\n isSubmenuPositioned = false;\n items: ContextMenuItem[] = [];\n\n openSubmenuId: string | null = null;\n activeSubmenuTrigger: HTMLElement | null = null; // Store the button that triggered the submenu\n\n activeIndex = -1;\n private stateSubscription: any;\n private submenuTimeout: any;\n\n protected readonly cn = cn;\n\n ngOnInit() {\n this.stateSubscription = this.contextMenuService.stateChanged.subscribe(\n (state: ContextMenuState) => {\n this.isOpen = state.isOpen;\n this.items = state.items;\n\n if (state.isOpen) {\n this.activeIndex = -1;\n this.isPositioned = false;\n this.cdr.detectChanges();\n requestAnimationFrame(() => {\n this.positionMenu();\n this.focusFirstItem();\n });\n } else {\n this.closeSubmenu();\n this.activeIndex = -1;\n this.isPositioned = false;\n }\n }\n );\n }\n\n // Helper to get items for the currently open submenu\n get activeSubmenuItems(): ContextMenuItem[] {\n if (!this.openSubmenuId) return [];\n return this.items.find(i => i.id === this.openSubmenuId)?.submenu || [];\n }\n\n @HostListener('document:click', ['$event'])\n @HostListener('document:contextmenu', ['$event'])\n onDocumentEvents(event: MouseEvent) {\n if (!this.isOpen) return;\n\n // Check if click is inside main menu OR the active submenu\n const clickedInsideMenu = this.menuElement?.nativeElement.contains(event.target as Node);\n const clickedInsideSubmenu = this.submenuElement?.nativeElement.contains(event.target as Node);\n\n if (clickedInsideMenu || clickedInsideSubmenu) {\n return;\n }\n\n this.contextMenuService.close();\n }\n\n async positionMenu() {\n if (this.menuElement?.nativeElement) {\n this.isPositioned = false;\n this.cdr.detectChanges();\n await this.contextMenuService.positionMenu(this.menuElement.nativeElement);\n this.isPositioned = true;\n this.cdr.detectChanges();\n // Focus the menu container so Arrow/Enter/Escape keydown handlers fire.\n this.menuElement.nativeElement.focus();\n }\n }\n\n focusFirstItem() {\n const firstEnabledIndex = this.items.findIndex(item => !item.disabled && !item.separator);\n if (firstEnabledIndex !== -1) {\n this.activeIndex = firstEnabledIndex;\n }\n }\n\n onItemClick(item: ContextMenuItem) {\n if (item.disabled || item.separator) return;\n\n if (item.submenu) {\n // Toggle logic handled by enter/leave mostly, but click can force toggle\n if (this.openSubmenuId === item.id) {\n this.closeSubmenu();\n } else {\n // We need the button ref to open it via click.\n // Implementation detail: Hover is preferred for submenus.\n }\n } else {\n this.contextMenuService.performAction(item.id!);\n }\n }\n\n onItemMouseEnter(item: ContextMenuItem, trigger: HTMLButtonElement) {\n if (item.disabled || item.separator) return;\n\n // Clear any pending close actions\n clearTimeout(this.submenuTimeout);\n\n this.submenuTimeout = setTimeout(() => {\n if (item.submenu) {\n this.openSubmenuId = item.id!;\n this.activeSubmenuTrigger = trigger; // Save the trigger for positioning\n this.positionSubmenu();\n } else {\n this.closeSubmenu();\n }\n }, 150);\n }\n\n onItemMouseLeave() {\n // We don't close immediately; we wait to see if the user enters the submenu\n // Logic handled by the timeout in `onItemMouseEnter` of a sibling,\n // or we could add a timeout here to close if they go nowhere.\n }\n\n onSubmenuMouseEnter() {\n // If user is hovering the submenu, keep it open\n clearTimeout(this.submenuTimeout);\n }\n\n onSubmenuMouseLeave() {\n // If user leaves submenu, standard behavior is usually to keep it open\n // until they hover another main item or click away.\n }\n\n private closeSubmenu() {\n this.openSubmenuId = null;\n this.activeSubmenuTrigger = null;\n this.isSubmenuPositioned = false;\n }\n\n private async positionSubmenu() {\n this.isSubmenuPositioned = false;\n this.cdr.detectChanges(); // Render the submenu DOM\n\n requestAnimationFrame(async () => {\n if (this.activeSubmenuTrigger && this.submenuElement) {\n await this.contextMenuService.positionSubmenu(\n this.activeSubmenuTrigger,\n this.submenuElement.nativeElement\n );\n this.isSubmenuPositioned = true;\n this.cdr.detectChanges();\n }\n });\n }\n\n onSubItemClick(item: ContextMenuItem) {\n if (item.disabled || item.separator) return;\n this.contextMenuService.performAction(item.id!);\n }\n\n onKeyDown(event: KeyboardEvent) {\n switch (event.key) {\n case 'ArrowDown':\n event.preventDefault();\n this.navigate(1);\n break;\n case 'ArrowUp':\n event.preventDefault();\n this.navigate(-1);\n break;\n case 'ArrowRight':\n const currentItem = this.items[this.activeIndex];\n if (currentItem?.submenu) {\n event.preventDefault();\n // Force open submenu via keyboard\n this.openSubmenuId = currentItem.id!;\n const trigger = this.itemButtons.toArray()[this.activeIndex]?.nativeElement;\n if (trigger) {\n this.activeSubmenuTrigger = trigger;\n this.positionSubmenu();\n }\n }\n break;\n case 'ArrowLeft':\n if (this.openSubmenuId) {\n event.preventDefault();\n this.closeSubmenu();\n }\n break;\n case 'Enter':\n if (this.activeIndex !== -1) {\n event.preventDefault();\n this.onItemClick(this.items[this.activeIndex]);\n }\n break;\n case 'Escape':\n event.preventDefault();\n this.contextMenuService.close();\n break;\n }\n }\n\n private navigate(direction: number) {\n let nextIndex = this.activeIndex + direction;\n const count = this.items.length;\n while (nextIndex >= 0 && nextIndex < count) {\n const item = this.items[nextIndex];\n if (!item.disabled && !item.separator) {\n this.activeIndex = nextIndex;\n return;\n }\n nextIndex += direction;\n }\n }\n\n @HostListener('document:keydown.escape')\n onEscape() {\n this.contextMenuService.close();\n }\n\n ngOnDestroy() {\n if (this.stateSubscription) {\n this.stateSubscription.unsubscribe();\n }\n clearTimeout(this.submenuTimeout);\n }\n}\n"
16
+ },
17
+ {
18
+ "path": "context-menu.service.ts",
19
+ "target": "ui/context-menu.service.ts",
20
+ "content": "import { Injectable, EventEmitter } from '@angular/core';\nimport { computePosition, offset, shift, flip, size } from '@floating-ui/dom';\n\nexport type ContextMenuItem = {\n id?: string;\n label?: string;\n icon?: string;\n disabled?: boolean;\n destructive?: boolean;\n separator?: boolean;\n submenu?: ContextMenuItem[];\n shortcut?: string;\n checked?: boolean;\n type?: 'default' | 'checkbox' | 'radio';\n}\n\nexport type ContextMenuState = {\n x: number;\n y: number;\n items: ContextMenuItem[];\n isOpen: boolean;\n triggerElement?: HTMLElement;\n onAction?: (actionId: string) => void;\n}\n\n@Injectable({\n providedIn: 'root'\n})\nexport class ContextMenuService {\n private _state: ContextMenuState = {\n x: 0,\n y: 0,\n items: [],\n isOpen: false\n };\n\n readonly stateChanged = new EventEmitter<ContextMenuState>();\n\n open(config: {\n event: MouseEvent;\n items?: ContextMenuItem[];\n triggerElement?: HTMLElement;\n onAction?: (actionId: string) => void;\n }) {\n const { event, items = [], triggerElement, onAction } = config;\n\n // Prevent browser context menu\n event.preventDefault();\n\n this._state = {\n x: event.clientX,\n y: event.clientY,\n items,\n isOpen: true,\n triggerElement,\n onAction\n };\n\n this.stateChanged.emit(this._state);\n }\n\n close() {\n this._state = { ...this._state, isOpen: false };\n this.stateChanged.emit(this._state);\n }\n\n async positionMenu(menuElement: HTMLElement) {\n const state = this._state;\n if (!state.isOpen || !menuElement) return;\n\n // Create a precise virtual element for the mouse coordinates\n const virtualEl = {\n getBoundingClientRect() {\n return {\n width: 0,\n height: 0,\n x: state.x,\n y: state.y,\n top: state.y,\n left: state.x,\n right: state.x,\n bottom: state.y,\n };\n }\n };\n\n const { x, y } = await computePosition(virtualEl as any, menuElement, {\n placement: 'bottom-start', // Standard: Menu top-left aligns with mouse\n strategy: 'fixed',\n middleware: [\n offset(2), // Slight offset so mouse isn't covering the first pixel\n flip({\n padding: 10,\n fallbackPlacements: ['bottom-end', 'top-start', 'top-end']\n }),\n shift({ padding: 10 }),\n size({\n apply({ availableHeight, availableWidth, elements }) {\n Object.assign(elements.floating.style, {\n maxHeight: `${Math.max(100, availableHeight - 16)}px`,\n maxWidth: `${Math.max(200, availableWidth - 16)}px`,\n overflowY: 'auto'\n });\n }\n })\n ]\n });\n\n // Position via left/top, not transform: the menu's own\n // data-[state=open]:zoom-in-95/slide-in-from-top-2 enter animation also\n // drives `transform`, and a CSS animation overrides whatever an inline\n // transform says for as long as it's running — so a translate() here\n // would get clobbered by the animation and only \"snap\" into place once\n // it finishes, which is exactly the top-left-then-jump flash this avoids.\n Object.assign(menuElement.style, {\n position: 'fixed',\n left: `${Math.round(x)}px`,\n top: `${Math.round(y)}px`,\n });\n }\n\n async positionSubmenu(triggerElement: HTMLElement, submenuElement: HTMLElement) {\n if (!triggerElement || !submenuElement) return;\n\n const { x, y } = await computePosition(triggerElement, submenuElement, {\n placement: 'right-start', // Standard: Submenu opens to the right\n strategy: 'fixed',\n middleware: [\n offset(-4), // Overlap slightly with parent menu\n flip({\n padding: 10,\n fallbackPlacements: ['left-start', 'bottom-start']\n }),\n shift({ padding: 10 }),\n size({\n apply({ availableHeight, availableWidth, elements }) {\n Object.assign(elements.floating.style, {\n maxHeight: `${Math.max(100, availableHeight - 16)}px`,\n maxWidth: `${Math.max(200, availableWidth - 16)}px`,\n overflowY: 'auto'\n });\n }\n })\n ]\n });\n\n Object.assign(submenuElement.style, {\n position: 'fixed',\n left: `${Math.round(x)}px`,\n top: `${Math.round(y)}px`,\n });\n }\n\n performAction(actionId: string) {\n this._state.onAction?.(actionId);\n this.close();\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
+ }