@tolle_/tolle-ui 18.2.30 → 18.3.1

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 (55) hide show
  1. package/esm2022/lib/alert-dialog-dynamic.component.mjs +8 -3
  2. package/esm2022/lib/alert-dialog.component.mjs +39 -6
  3. package/esm2022/lib/alert-dialog.service.mjs +38 -6
  4. package/esm2022/lib/bar-list.component.mjs +95 -0
  5. package/esm2022/lib/category-bar.component.mjs +130 -0
  6. package/esm2022/lib/chart-spark.component.mjs +120 -0
  7. package/esm2022/lib/chart.component.mjs +131 -45
  8. package/esm2022/lib/chart.service.mjs +33 -8
  9. package/esm2022/lib/context-menu.service.mjs +11 -7
  10. package/esm2022/lib/modal-ref.mjs +36 -7
  11. package/esm2022/lib/modal.component.mjs +42 -28
  12. package/esm2022/lib/modal.mjs +2 -29
  13. package/esm2022/lib/modal.service.mjs +26 -4
  14. package/esm2022/lib/progress-circle.component.mjs +138 -0
  15. package/esm2022/lib/resizable-panel.component.mjs +5 -5
  16. package/esm2022/lib/segment.component.mjs +3 -3
  17. package/esm2022/lib/tracker.component.mjs +84 -0
  18. package/esm2022/public-api.mjs +6 -1
  19. package/fesm2022/tolle-ui.mjs +900 -139
  20. package/fesm2022/tolle-ui.mjs.map +1 -1
  21. package/lib/alert-dialog-dynamic.component.d.ts +5 -0
  22. package/lib/alert-dialog.component.d.ts +13 -0
  23. package/lib/bar-list.component.d.ts +39 -0
  24. package/lib/category-bar.component.d.ts +49 -0
  25. package/lib/chart-spark.component.d.ts +49 -0
  26. package/lib/chart.component.d.ts +68 -9
  27. package/lib/chart.service.d.ts +25 -5
  28. package/lib/modal-ref.d.ts +6 -1
  29. package/lib/modal.component.d.ts +7 -4
  30. package/lib/modal.d.ts +9 -3
  31. package/lib/progress-circle.component.d.ts +35 -0
  32. package/lib/tracker.component.d.ts +39 -0
  33. package/package.json +3 -2
  34. package/preset.js +7 -1
  35. package/public-api.d.ts +5 -0
  36. package/registry/docs-content.json +330 -2
  37. package/registry/llms-full.txt +98 -2
  38. package/registry/llms.txt +5 -0
  39. package/registry/manifest.json +380 -3
  40. package/registry/r/alert-dialog-dynamic.json +1 -1
  41. package/registry/r/alert-dialog.json +1 -1
  42. package/registry/r/bar-list.json +21 -0
  43. package/registry/r/category-bar.json +21 -0
  44. package/registry/r/chart-pie.json +1 -1
  45. package/registry/r/chart-spark.json +30 -0
  46. package/registry/r/chart.json +2 -2
  47. package/registry/r/context-menu-trigger.json +1 -1
  48. package/registry/r/context-menu.json +1 -1
  49. package/registry/r/modal.json +3 -3
  50. package/registry/r/progress-circle.json +21 -0
  51. package/registry/r/resizable-panel.json +1 -1
  52. package/registry/r/segment.json +1 -1
  53. package/registry/r/tracker.json +25 -0
  54. package/registry/registry.json +112 -0
  55. package/theme.css +15 -3
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "modal-ref.ts",
12
12
  "target": "ui/modal-ref.ts",
13
- "content": "import { OverlayRef } from '@angular/cdk/overlay';\nimport { Subject } from 'rxjs';\nimport { Modal } from './modal';\nimport { ModalStackService } from './modal-stack.service';\n\nexport class ModalRef<R = any> {\n private readonly _afterClosed$ = new Subject<R | undefined | null>();\n afterClosed$ = this._afterClosed$.asObservable();\n\n constructor(\n private overlay: OverlayRef,\n public modal: Modal,\n private stack: ModalStackService\n ) {\n this.stack.register(this);\n\n // Handle Backdrop Click\n this.overlay.backdropClick().subscribe(() => {\n if (this.modal.backdropClose) {\n this.close();\n }\n });\n }\n\n /**\n * Closes the modal instantly.\n * @param result Data to pass back to the caller\n */\n close(result?: R): void {\n this._afterClosed$.next(result);\n this._afterClosed$.complete();\n this.overlay.dispose(); // Instant disposal (No animation timer)\n this.stack.unregister(this);\n }\n}\n"
13
+ "content": "import { OverlayRef } from '@angular/cdk/overlay';\nimport { BehaviorSubject, Subject } from 'rxjs';\nimport { Modal } from './modal';\nimport { ModalStackService } from './modal-stack.service';\n\n/**\n * Safety net if the exit animation's `animationend` never fires — reduced\n * motion, no matching keyframe, or a panel that never rendered — so a modal\n * can never get stuck open forever.\n */\nconst CLOSE_FALLBACK_MS = 300;\n\nexport class ModalRef<R = any> {\n private readonly _afterClosed$ = new Subject<R | undefined | null>();\n afterClosed$ = this._afterClosed$.asObservable();\n\n private readonly _closingSubject = new BehaviorSubject<boolean>(false);\n /** True once `close()` has been called — drives the panel's exit animation. */\n readonly closing$ = this._closingSubject.asObservable();\n\n constructor(\n private overlay: OverlayRef,\n public modal: Modal,\n private stack: ModalStackService\n ) {\n this.stack.register(this);\n\n // Handle Backdrop Click\n this.overlay.backdropClick().subscribe(() => {\n if (this.modal.backdropClose) {\n this.close();\n }\n });\n }\n\n /**\n * Closes the modal. Signals the panel and backdrop to play their exit\n * animation, then tears down the overlay once it finishes (or after\n * `CLOSE_FALLBACK_MS`, whichever comes first).\n * @param result Data to pass back to the caller\n */\n close(result?: R): void {\n if (this._closingSubject.value) return;\n this._closingSubject.next(true);\n this.overlay.backdropElement?.setAttribute('data-state', 'closed');\n\n let finished = false;\n const finish = () => {\n if (finished) return;\n finished = true;\n this._afterClosed$.next(result);\n this._afterClosed$.complete();\n this.overlay.dispose();\n this.stack.unregister(this);\n };\n\n const panel = this.overlay.overlayElement.querySelector<HTMLElement>('[data-state]');\n if (panel) {\n panel.addEventListener('animationend', finish, { once: true });\n setTimeout(finish, CLOSE_FALLBACK_MS);\n } else {\n finish();\n }\n }\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "modal-stack.service.ts",
@@ -20,12 +20,12 @@
20
20
  {
21
21
  "path": "modal.component.ts",
22
22
  "target": "ui/modal.component.ts",
23
- "content": "import { Component, OnInit, TemplateRef, Type } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { ModalRef } from './modal-ref';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-modal',\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `\n <div [class]=\"modalClasses\" class=\"pointer-events-auto\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"ref.modal.title ? titleId : null\"\n [attr.aria-describedby]=\"contentType === 'string' ? descId : null\"\n cdkTrapFocus cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n\n <!-- Header -->\n <div *ngIf=\"hasHeader\" class=\"flex shrink-0 items-start justify-between gap-4 px-6 pt-6 pb-4\">\n <h2 *ngIf=\"ref.modal.title\" [id]=\"titleId\" class=\"text-lg font-semibold leading-none tracking-tight text-foreground\">\n {{ ref.modal.title }}\n </h2>\n <button\n *ngIf=\"ref.modal.showCloseButton\"\n type=\"button\"\n (click)=\"ref.close()\"\n aria-label=\"Close\"\n class=\"-mr-2 -mt-2 ml-auto grid h-8 w-8 shrink-0 place-items-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring\">\n <i class=\"ri-close-line text-xl\"></i>\n </button>\n </div>\n\n <!-- Body -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n <ng-container [ngSwitch]=\"contentType\">\n <p *ngSwitchCase=\"'string'\" [id]=\"descId\" [class]=\"hasHeader ? 'px-6 pb-6 text-sm leading-relaxed text-muted-foreground' : 'p-6 text-sm leading-relaxed text-muted-foreground'\">{{ content }}</p>\n\n <ng-container *ngSwitchCase=\"'template'\">\n <ng-container *ngTemplateOutlet=\"asTemplate; context: ref.modal.context\"></ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'component'\">\n <ng-container *ngComponentOutlet=\"asComponent\"></ng-container>\n </ng-container>\n </ng-container>\n </div>\n </div>\n `,\n styles: [`\n :host {\n display: contents; /* This makes the host \"disappear\" so the div targets the overlay pane directly */\n }\n `]\n})\nexport class ModalComponent implements OnInit {\n contentType: 'template' | 'string' | 'component' = 'string';\n content: any;\n modalClasses = '';\n\n /** Stable, per-instance ids used to wire dialog ARIA relationships. */\n private readonly _uid = Math.random().toString(36).substr(2, 9);\n readonly titleId = `tolle-modal-title-${this._uid}`;\n readonly descId = `tolle-modal-desc-${this._uid}`;\n\n constructor(public ref: ModalRef) {}\n\n ngOnInit() {\n this.content = this.ref.modal.content;\n this.modalClasses = this.getModalSizeCss();\n this.determineContentType();\n }\n\n /** Whether the auto-rendered header (title and/or close button) is shown. */\n get hasHeader(): boolean {\n return !!(this.ref.modal.showCloseButton || this.ref.modal.title);\n }\n\n private getModalSizeCss(): string {\n const { size } = this.ref.modal;\n\n return cn(\n // Surface: overlay card that never exceeds the viewport (header stays, body scrolls).\n 'bg-background text-foreground border border-border shadow-lg relative flex flex-col w-full mx-auto',\n\n size === 'fullscreen' ? 'h-screen w-screen rounded-none' : 'rounded-lg max-h-[85vh]',\n\n // Sizing scale with explicit max-widths\n size === 'xs' && 'max-w-[320px]',\n size === 'sm' && 'max-w-[425px]',\n size === 'default' && 'max-w-[512px]',\n size === 'lg' && 'max-w-[1024px]',\n size === 'xl' && 'max-w-[1280px]'\n );\n }\n\n private determineContentType() {\n if (typeof this.content === 'string') this.contentType = 'string';\n else if (this.content instanceof TemplateRef) this.contentType = 'template';\n else this.contentType = 'component';\n }\n\n get asTemplate() { return this.content as TemplateRef<any>; }\n get asComponent() { return this.content as Type<any>; }\n protected cn = cn;\n}\n"
23
+ "content": "import { ChangeDetectorRef, Component, inject, OnDestroy, OnInit, TemplateRef, Type } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { Subscription } from 'rxjs';\nimport { ModalRef } from './modal-ref';\nimport { cn } from './utils/cn';\n\n@Component({\n selector: 'tolle-modal',\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `\n <div [class]=\"modalClasses\" class=\"pointer-events-auto\"\n [attr.data-state]=\"closing ? 'closed' : 'open'\"\n role=\"dialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"ref.modal.title ? titleId : null\"\n [attr.aria-describedby]=\"contentType === 'string' ? descId : null\"\n cdkTrapFocus cdkTrapFocusAutoCapture\n (mousedown)=\"$event.stopPropagation()\">\n\n <button\n *ngIf=\"ref.modal.showCloseButton\"\n type=\"button\"\n (click)=\"ref.close()\"\n aria-label=\"Close\"\n class=\"absolute right-4 top-4 grid h-6 w-6 place-items-center rounded-sm text-muted-foreground opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2\">\n <i class=\"ri-close-line text-base\" aria-hidden=\"true\"></i>\n </button>\n\n <!-- Header -->\n <div *ngIf=\"ref.modal.title\" class=\"flex shrink-0 flex-col gap-1.5 pr-8 text-center sm:text-left\">\n <h2 [id]=\"titleId\" class=\"text-lg font-semibold leading-none tracking-tight text-foreground\">\n {{ ref.modal.title }}\n </h2>\n </div>\n\n <!-- Body -->\n <div class=\"min-h-0 flex-1 overflow-y-auto\">\n <ng-container [ngSwitch]=\"contentType\">\n <p *ngSwitchCase=\"'string'\" [id]=\"descId\" class=\"text-sm leading-relaxed text-muted-foreground\">{{ content }}</p>\n\n <ng-container *ngSwitchCase=\"'template'\">\n <ng-container *ngTemplateOutlet=\"asTemplate; context: ref.modal.context\"></ng-container>\n </ng-container>\n\n <ng-container *ngSwitchCase=\"'component'\">\n <ng-container *ngComponentOutlet=\"asComponent\"></ng-container>\n </ng-container>\n </ng-container>\n </div>\n </div>\n `,\n styles: [`\n :host {\n display: contents; /* This makes the host \"disappear\" so the div targets the overlay pane directly */\n }\n `]\n})\nexport class ModalComponent implements OnInit, OnDestroy {\n private readonly cdr = inject(ChangeDetectorRef);\n private readonly subscription = new Subscription();\n\n contentType: 'template' | 'string' | 'component' = 'string';\n content: any;\n modalClasses = '';\n /** True while the exit animation plays, just before the overlay is torn down. */\n closing = false;\n\n /** Stable, per-instance ids used to wire dialog ARIA relationships. */\n private readonly _uid = Math.random().toString(36).substr(2, 9);\n readonly titleId = `tolle-modal-title-${this._uid}`;\n readonly descId = `tolle-modal-desc-${this._uid}`;\n\n constructor(public ref: ModalRef) {}\n\n ngOnInit() {\n this.content = this.ref.modal.content;\n this.modalClasses = this.getModalSizeCss();\n this.determineContentType();\n\n this.subscription.add(\n this.ref.closing$.subscribe((closing) => {\n this.closing = closing;\n this.cdr.markForCheck();\n })\n );\n }\n\n ngOnDestroy() {\n this.subscription.unsubscribe();\n }\n\n private getModalSizeCss(): string {\n const { size } = this.ref.modal;\n\n return cn(\n // Surface: overlay card that never exceeds the viewport (header stays, body scrolls).\n 'relative flex w-full mx-auto flex-col gap-4 rounded-lg border border-border bg-background p-6 text-foreground shadow-lg duration-200',\n\n // Enter/exit animation, driven by `data-state` (see `closing` above).\n 'data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95',\n 'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',\n\n size === 'fullscreen' ? 'h-screen w-screen rounded-none' : 'max-h-[85vh]',\n\n // Sizing scale with explicit max-widths\n size === 'xs' && 'max-w-[320px]',\n size === 'sm' && 'max-w-[425px]',\n size === 'default' && 'max-w-[512px]',\n size === 'lg' && 'max-w-[1024px]',\n size === 'xl' && 'max-w-[1280px]'\n );\n }\n\n private determineContentType() {\n if (typeof this.content === 'string') this.contentType = 'string';\n else if (this.content instanceof TemplateRef) this.contentType = 'template';\n else this.contentType = 'component';\n }\n\n get asTemplate() { return this.content as TemplateRef<any>; }\n get asComponent() { return this.content as Type<any>; }\n protected cn = cn;\n}\n"
24
24
  },
25
25
  {
26
26
  "path": "modal.ts",
27
27
  "target": "ui/modal.ts",
28
- "content": "import { TemplateRef, Type } from \"@angular/core\";\n\nexport class Modal<T = any> {\n /** The content to display (String, Component, or Template) */\n content!: string | Type<any> | TemplateRef<any>;\n\n /** Optional title for the standard header */\n title?: string;\n\n /** * Predefined size scale.\n * - xs: 320px (Mobile alerts)\n * - sm: 425px (Standard dialogs)\n * - default: 544px (Forms)\n * - lg: 90% / 1024px (Data tables)\n * - xl: 1280px (Large forms, dashboards)\n * - fullscreen: 100vw/100vh (Complex workflows)\n */\n size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | 'fullscreen' = 'default';\n\n /** * If true (default), clicking the backdrop closes the modal.\n * Set to false for \"blocking\" modals (e.g., Terms of Service).\n */\n backdropClose?: boolean = true;\n\n /** * Data to pass to a Component content.\n * Accessed via @Input() in the child component.\n */\n data?: { [key: string]: any };\n\n /** * Context to pass to a TemplateRef content.\n * Accessed via `let-val` in the HTML.\n */\n context?: T;\n\n showCloseButton?: boolean = true;\n}\n"
28
+ "content": "import { TemplateRef, Type } from \"@angular/core\";\n\n/**\n * Configuration for `ModalService.open()`. Always passed as a plain object\n * literal, so unset fields fall back to `MODAL_DEFAULTS` in `modal.service.ts`\n * rather than a class field initializer — those never run for a literal.\n */\nexport interface Modal<T = any> {\n /** The content to display (String, Component, or Template) */\n content: string | Type<any> | TemplateRef<any>;\n\n /** Optional title for the standard header */\n title?: string;\n\n /** * Predefined size scale.\n * - xs: 320px (Mobile alerts)\n * - sm: 425px (Standard dialogs)\n * - default: 512px (Forms)\n * - lg: 1024px (Data tables)\n * - xl: 1280px (Large forms, dashboards)\n * - fullscreen: 100vw/100vh (Complex workflows)\n */\n size?: 'xs' | 'sm' | 'default' | 'lg' | 'xl' | 'fullscreen';\n\n /** * If true (default), clicking the backdrop closes the modal.\n * Set to false for \"blocking\" modals (e.g., Terms of Service).\n */\n backdropClose?: boolean;\n\n /** * Data to pass to a Component content.\n * Accessed via @Input() in the child component.\n */\n data?: { [key: string]: any };\n\n /** * Context to pass to a TemplateRef content.\n * Accessed via `let-val` in the HTML.\n */\n context?: T;\n\n /** Whether the built-in corner close button renders. Defaults to true. */\n showCloseButton?: boolean;\n}\n"
29
29
  },
30
30
  {
31
31
  "path": "utils/cn.ts",
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "progress-circle",
3
+ "type": "registry:ui",
4
+ "title": "Progress Circle",
5
+ "description": "Progress Circle component.",
6
+ "category": "feedback",
7
+ "registryDependencies": [],
8
+ "dependencies": [],
9
+ "files": [
10
+ {
11
+ "path": "progress-circle.component.ts",
12
+ "target": "ui/progress-circle.component.ts",
13
+ "content": "import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\n\n/**\n * Radial counterpart to `tolle-progress`: a ring that fills clockwise from\n * 12 o'clock. Project content to label the centre — a percentage, an icon,\n * whatever the caller wants; the ring renders nothing there on its own.\n * @new\n */\n@Component({\n selector: 'tolle-progress-circle',\n styles: [':host { display: inline-flex; }'],\n standalone: true,\n imports: [CommonModule],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div\n [class]=\"computedClass\"\n [style.width.px]=\"size\"\n [style.height.px]=\"size\"\n role=\"progressbar\"\n [attr.aria-valuemin]=\"0\"\n [attr.aria-valuemax]=\"100\"\n [attr.aria-valuenow]=\"clampedValue\"\n >\n <svg [attr.width]=\"size\" [attr.height]=\"size\" [attr.viewBox]=\"viewBox\" class=\"block\">\n <svg:circle\n [attr.cx]=\"center\"\n [attr.cy]=\"center\"\n [attr.r]=\"radius\"\n fill=\"none\"\n [attr.stroke-width]=\"strokeWidth\"\n class=\"stroke-primary/20\"\n ></svg:circle>\n <svg:circle\n [attr.cx]=\"center\"\n [attr.cy]=\"center\"\n [attr.r]=\"radius\"\n fill=\"none\"\n [attr.stroke-width]=\"strokeWidth\"\n stroke-linecap=\"round\"\n [attr.stroke-dasharray]=\"circumference\"\n [attr.stroke-dashoffset]=\"dashOffset\"\n [attr.transform]=\"'rotate(-90 ' + center + ' ' + center + ')'\"\n class=\"stroke-primary transition-[stroke-dashoffset] duration-300 ease-in-out\"\n ></svg:circle>\n </svg>\n <div class=\"absolute inset-0 flex items-center justify-center text-sm font-medium tabular-nums text-foreground\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n})\nexport class ProgressCircleComponent implements OnChanges {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Percent complete, 0-100. @default 0 */\n @Input() value: number | null = 0;\n /** Diameter of the ring in px. @default 120 */\n @Input() size = 120;\n /** Thickness of the ring in px. @default 8 */\n @Input() strokeWidth = 8;\n /** Extra Tailwind classes merged onto the ring via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly cdr = inject(ChangeDetectorRef);\n\n get clampedValue(): number {\n return Math.min(100, Math.max(0, this.value ?? 0));\n }\n\n get viewBox(): string {\n return '0 0 ' + this.size + ' ' + this.size;\n }\n\n get center(): number {\n return this.size / 2;\n }\n\n /** Leaves half the stroke width as margin so the ring never clips the frame. */\n get radius(): number {\n return Math.max(0, (this.size - this.strokeWidth) / 2);\n }\n\n get circumference(): number {\n return 2 * Math.PI * this.radius;\n }\n\n get dashOffset(): number {\n return this.circumference * (1 - this.clampedValue / 100);\n }\n\n get computedClass() {\n return cn('relative inline-flex shrink-0 items-center justify-center', 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
+ }
@@ -12,7 +12,7 @@
12
12
  {
13
13
  "path": "resizable-panel.component.ts",
14
14
  "target": "ui/resizable-panel.component.ts",
15
- "content": "import { Component, Input, ContentChildren, QueryList, AfterContentInit, ElementRef, inject, ChangeDetectorRef, OnDestroy, forwardRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { ResizablePanelItemComponent } from './resizable-panel-item.component';\n\n@Component({\n selector: 'tolle-resizable-panel',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"computedContainerClass\">\n <div class=\"flex\" [class.flex-col]=\"direction === 'vertical'\" [class.flex-row]=\"direction === 'horizontal'\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n styles: [`\n :host {\n display: block;\n }\n `]\n})\nexport class ResizablePanelComponent implements AfterContentInit, OnDestroy {\n @Input() direction: 'horizontal' | 'vertical' = 'horizontal';\n @Input() class: string = '';\n\n // forwardRef breaks the panel <-> item circular import (TDZ at class-decoration time).\n @ContentChildren(forwardRef(() => ResizablePanelItemComponent)) panels!: QueryList<ResizablePanelItemComponent>;\n\n private el = inject(ElementRef);\n private cdr = inject(ChangeDetectorRef);\n\n private isResizing = false;\n private activePanelIndex = -1;\n private startCursorPosition = 0;\n private startPanelSizes: number[] = [];\n\n private mouseMoveListener?: (e: MouseEvent) => void;\n private mouseUpListener?: (e: MouseEvent) => void;\n\n ngAfterContentInit() {\n // Initialize panel sizes if needed\n setTimeout(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n\n this.panels.changes.subscribe(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n }\n\n ngOnDestroy() {\n this.cleanupListeners();\n }\n\n private updateLastStatus() {\n const panels = this.panels.toArray();\n panels.forEach((p, i) => p.isLast = i === panels.length - 1);\n }\n\n startResize(item: ResizablePanelItemComponent, event: MouseEvent) {\n const panels = this.panels.toArray();\n this.activePanelIndex = panels.indexOf(item);\n if (this.activePanelIndex === -1 || this.activePanelIndex === panels.length - 1) return;\n\n this.isResizing = true;\n this.startCursorPosition = this.direction === 'horizontal' ? event.clientX : event.clientY;\n this.startPanelSizes = panels.map(p => p.size);\n\n this.setupListeners();\n document.body.style.cursor = this.direction === 'horizontal' ? 'col-resize' : 'row-resize';\n document.body.style.userSelect = 'none';\n }\n\n private setupListeners() {\n this.mouseMoveListener = (e: MouseEvent) => this.onMouseMove(e);\n this.mouseUpListener = () => this.stopResize();\n\n document.addEventListener('mousemove', this.mouseMoveListener);\n document.addEventListener('mouseup', this.mouseUpListener);\n }\n\n private cleanupListeners() {\n if (this.mouseMoveListener) document.removeEventListener('mousemove', this.mouseMoveListener);\n if (this.mouseUpListener) document.removeEventListener('mouseup', this.mouseUpListener);\n }\n\n private onMouseMove(event: MouseEvent) {\n if (!this.isResizing) return;\n\n const currentPos = this.direction === 'horizontal' ? event.clientX : event.clientY;\n const deltaPx = currentPos - this.startCursorPosition;\n\n const containerSize = this.direction === 'horizontal'\n ? this.el.nativeElement.offsetWidth\n : this.el.nativeElement.offsetHeight;\n\n const deltaPercent = (deltaPx / containerSize) * 100;\n\n const panels = this.panels.toArray();\n const panelA = panels[this.activePanelIndex];\n const panelB = panels[this.activePanelIndex + 1];\n\n const newSizeA = Math.max(panelA.minSize, this.startPanelSizes[this.activePanelIndex] + deltaPercent);\n const actualDelta = newSizeA - this.startPanelSizes[this.activePanelIndex];\n const newSizeB = Math.max(panelB.minSize, this.startPanelSizes[this.activePanelIndex + 1] - actualDelta);\n\n panelA.size = newSizeA;\n panelB.size = this.startPanelSizes[this.activePanelIndex] + this.startPanelSizes[this.activePanelIndex + 1] - panelA.size;\n\n if (panelB.size < panelB.minSize) {\n panelB.size = panelB.minSize;\n panelA.size = (this.startPanelSizes[this.activePanelIndex] + this.startPanelSizes[this.activePanelIndex + 1]) - panelB.minSize;\n }\n\n this.cdr.detectChanges();\n }\n\n private stopResize() {\n this.isResizing = false;\n this.cleanupListeners();\n document.body.style.cursor = '';\n document.body.style.userSelect = '';\n }\n\n private updatePanelSizes() {\n const panels = this.panels.toArray();\n const totalPanels = panels.length;\n\n if (totalPanels > 0) {\n const defaultSize = 100 / totalPanels;\n panels.forEach(panel => {\n if (!panel.size) {\n panel.size = defaultSize;\n }\n });\n }\n }\n\n get computedContainerClass() {\n return cn(\n 'w-full h-full',\n this.class\n );\n }\n}\n"
15
+ "content": "import { Component, Input, ContentChildren, QueryList, AfterContentInit, ElementRef, inject, ChangeDetectorRef, OnDestroy, forwardRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { ResizablePanelItemComponent } from './resizable-panel-item.component';\n\n@Component({\n selector: 'tolle-resizable-panel',\n standalone: true,\n imports: [CommonModule],\n template: `\n <div [class]=\"computedContainerClass\">\n <div class=\"flex h-full w-full\" [class.flex-col]=\"direction === 'vertical'\" [class.flex-row]=\"direction === 'horizontal'\">\n <ng-content></ng-content>\n </div>\n </div>\n `,\n styles: [`\n /* Fills its parent by default so consumers don't have to remember a\n manual h-full — the flex row/col wrapper below only has something to\n distribute once this and the container div actually have a height. */\n :host {\n display: block;\n width: 100%;\n height: 100%;\n }\n `]\n})\nexport class ResizablePanelComponent implements AfterContentInit, OnDestroy {\n @Input() direction: 'horizontal' | 'vertical' = 'horizontal';\n @Input() class: string = '';\n\n // forwardRef breaks the panel <-> item circular import (TDZ at class-decoration time).\n @ContentChildren(forwardRef(() => ResizablePanelItemComponent)) panels!: QueryList<ResizablePanelItemComponent>;\n\n private el = inject(ElementRef);\n private cdr = inject(ChangeDetectorRef);\n\n private isResizing = false;\n private activePanelIndex = -1;\n private startCursorPosition = 0;\n private startPanelSizes: number[] = [];\n\n private mouseMoveListener?: (e: MouseEvent) => void;\n private mouseUpListener?: (e: MouseEvent) => void;\n\n ngAfterContentInit() {\n // Initialize panel sizes if needed\n setTimeout(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n\n this.panels.changes.subscribe(() => {\n this.updatePanelSizes();\n this.updateLastStatus();\n });\n }\n\n ngOnDestroy() {\n this.cleanupListeners();\n }\n\n private updateLastStatus() {\n const panels = this.panels.toArray();\n panels.forEach((p, i) => p.isLast = i === panels.length - 1);\n }\n\n startResize(item: ResizablePanelItemComponent, event: MouseEvent) {\n const panels = this.panels.toArray();\n this.activePanelIndex = panels.indexOf(item);\n if (this.activePanelIndex === -1 || this.activePanelIndex === panels.length - 1) return;\n\n this.isResizing = true;\n this.startCursorPosition = this.direction === 'horizontal' ? event.clientX : event.clientY;\n this.startPanelSizes = panels.map(p => p.size);\n\n this.setupListeners();\n document.body.style.cursor = this.direction === 'horizontal' ? 'col-resize' : 'row-resize';\n document.body.style.userSelect = 'none';\n }\n\n private setupListeners() {\n this.mouseMoveListener = (e: MouseEvent) => this.onMouseMove(e);\n this.mouseUpListener = () => this.stopResize();\n\n document.addEventListener('mousemove', this.mouseMoveListener);\n document.addEventListener('mouseup', this.mouseUpListener);\n }\n\n private cleanupListeners() {\n if (this.mouseMoveListener) document.removeEventListener('mousemove', this.mouseMoveListener);\n if (this.mouseUpListener) document.removeEventListener('mouseup', this.mouseUpListener);\n }\n\n private onMouseMove(event: MouseEvent) {\n if (!this.isResizing) return;\n\n const currentPos = this.direction === 'horizontal' ? event.clientX : event.clientY;\n const deltaPx = currentPos - this.startCursorPosition;\n\n const containerSize = this.direction === 'horizontal'\n ? this.el.nativeElement.offsetWidth\n : this.el.nativeElement.offsetHeight;\n\n const deltaPercent = (deltaPx / containerSize) * 100;\n\n const panels = this.panels.toArray();\n const panelA = panels[this.activePanelIndex];\n const panelB = panels[this.activePanelIndex + 1];\n\n const newSizeA = Math.max(panelA.minSize, this.startPanelSizes[this.activePanelIndex] + deltaPercent);\n const actualDelta = newSizeA - this.startPanelSizes[this.activePanelIndex];\n const newSizeB = Math.max(panelB.minSize, this.startPanelSizes[this.activePanelIndex + 1] - actualDelta);\n\n panelA.size = newSizeA;\n panelB.size = this.startPanelSizes[this.activePanelIndex] + this.startPanelSizes[this.activePanelIndex + 1] - panelA.size;\n\n if (panelB.size < panelB.minSize) {\n panelB.size = panelB.minSize;\n panelA.size = (this.startPanelSizes[this.activePanelIndex] + this.startPanelSizes[this.activePanelIndex + 1]) - panelB.minSize;\n }\n\n this.cdr.detectChanges();\n }\n\n private stopResize() {\n this.isResizing = false;\n this.cleanupListeners();\n document.body.style.cursor = '';\n document.body.style.userSelect = '';\n }\n\n private updatePanelSizes() {\n const panels = this.panels.toArray();\n const totalPanels = panels.length;\n\n if (totalPanels > 0) {\n const defaultSize = 100 / totalPanels;\n panels.forEach(panel => {\n if (!panel.size) {\n panel.size = defaultSize;\n }\n });\n }\n }\n\n get computedContainerClass() {\n return cn(\n 'w-full h-full',\n this.class\n );\n }\n}\n"
16
16
  },
17
17
  {
18
18
  "path": "utils/cn.ts",
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "segment.component.ts",
12
12
  "target": "ui/segment.component.ts",
13
- "content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChildren,\n QueryList,\n AfterViewInit,\n ChangeDetectorRef,\n OnChanges,\n OnDestroy,\n SimpleChanges,\n TemplateRef,\n ViewChild,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\nexport type SegmentItem = {\n label: string;\n value: any;\n disabled?: boolean;\n icon?: string; // Added: Optional icon class (e.g., 'ri-home-line')\n class?: string; // Added: Custom class for specific item wrapper\n data?: any; // Added: Extra data payload for custom templates\n}\n\n@Component({\n selector: 'tolle-segment',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SegmentComponent),\n multi: true\n }\n ],\n template: `\n <div\n #container\n [class]=\"cn(\n 'relative flex items-center p-1 bg-muted rounded-lg select-none w-full gap-1',\n class\n )\"\n role=\"radiogroup\"\n (keydown)=\"onKeydown($event)\"\n >\n <div\n class=\"absolute top-1 bottom-1 bg-primary shadow-sm rounded-md transition-all duration-300 ease-[cubic-bezier(0.2,0.0,0.2,1)]\"\n [style.left.px]=\"gliderLeft\"\n [style.width.px]=\"gliderWidth\"\n [class.opacity-0]=\"!hasValue\"\n ></div>\n\n <button\n *ngFor=\"let item of items\"\n #itemEls\n type=\"button\"\n role=\"radio\"\n [disabled]=\"item.disabled || disabled\"\n [attr.aria-checked]=\"value === item.value\"\n [attr.tabindex]=\"getTabIndex(item)\"\n (click)=\"select(item.value)\"\n [class]=\"cn(\n 'relative z-10 flex-1 px-3 py-1.5 text-sm font-medium transition-colors duration-200 rounded-md text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n 'flex items-center justify-center gap-2',\n value === item.value\n ? 'text-primary-foreground'\n : 'text-muted-foreground hover:text-foreground/70',\n item.disabled && 'opacity-50 cursor-not-allowed',\n item.class\n )\"\n >\n <ng-container *ngIf=\"itemTemplate; else defaultContent\">\n <ng-container *ngTemplateOutlet=\"itemTemplate; context: { $implicit: item, selected: value === item.value }\">\n </ng-container>\n </ng-container>\n\n <ng-template #defaultContent>\n <i *ngIf=\"item.icon\" [class]=\"item.icon\"></i>\n <span class=\"truncate\">{{ item.label }}</span>\n </ng-template>\n </button>\n </div>\n `,\n styles: [`\n :host {\n display: block;\n }\n `]\n})\nexport class SegmentComponent implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy {\n @Input() items: SegmentItem[] = [];\n @Input() class = '';\n @Input() disabled = false;\n @Input() itemTemplate?: TemplateRef<any>; // Allow custom content\n\n value: any = null;\n gliderLeft = 0;\n gliderWidth = 0;\n hasValue = false;\n\n @ViewChild('container') containerEl!: ElementRef<HTMLElement>;\n @ViewChildren('itemEls') itemElements!: QueryList<ElementRef<HTMLElement>>;\n\n private resizeObserver?: ResizeObserver;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n ngAfterViewInit() {\n setTimeout(() => this.updateGlider());\n\n if (typeof ResizeObserver !== 'undefined' && this.containerEl) {\n this.resizeObserver = new ResizeObserver(() => {\n this.updateGlider();\n });\n this.resizeObserver.observe(this.containerEl.nativeElement);\n }\n }\n\n ngOnDestroy() {\n if (this.resizeObserver) {\n this.resizeObserver.disconnect();\n }\n }\n\n ngOnChanges(changes: SimpleChanges) {\n // Recalculate if items change or if value changes externally\n if (changes['items'] && !changes['items'].firstChange) {\n setTimeout(() => this.updateGlider());\n }\n }\n\n select(val: any) {\n if (this.disabled) return;\n const item = this.items.find(i => i.value === val);\n if (item?.disabled) return;\n\n this.value = val;\n this.onChange(val);\n this.onTouched();\n this.updateGlider();\n }\n\n /** True when the current value matches one of the items. */\n private hasSelectedValue(): boolean {\n return this.items.some(i => i.value === this.value);\n }\n\n private firstEnabledItem(): SegmentItem | undefined {\n if (this.disabled) return undefined;\n return this.items.find(i => !i.disabled);\n }\n\n /**\n * Roving tabindex: the selected item is tab-reachable, or - when nothing is\n * selected - the first enabled item. All other items get -1.\n */\n getTabIndex(item: SegmentItem): number {\n if (this.disabled || item.disabled) return -1;\n if (this.value === item.value) return 0;\n if (!this.hasSelectedValue() && this.firstEnabledItem() === item) return 0;\n return -1;\n }\n\n /**\n * WAI-ARIA radio group keyboard nav: Arrow Left/Right (and Home/End) move\n * selection + focus across the enabled items, wrapping and skipping disabled.\n */\n onKeydown(event: KeyboardEvent) {\n if (this.disabled) return;\n\n const key = event.key;\n const forward = key === 'ArrowRight';\n const backward = key === 'ArrowLeft';\n const home = key === 'Home';\n const end = key === 'End';\n if (!forward && !backward && !home && !end) return;\n\n const enabled = this.items.map((it, i) => ({ it, i })).filter(x => !x.it.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const buttons = this.itemElements?.toArray().map(r => r.nativeElement) ?? [];\n const focusedIndex = buttons.indexOf(event.target as HTMLElement);\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n if (pos < 0) pos = enabled.findIndex(x => x.it.value === this.value);\n\n let targetPos: number;\n if (home) {\n targetPos = 0;\n } else if (end) {\n targetPos = enabled.length - 1;\n } else if (forward) {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n const target = enabled[targetPos];\n this.select(target.it.value);\n this.itemElements.get(target.i)?.nativeElement.focus();\n }\n\n updateGlider() {\n if (!this.itemElements || !this.items.length) return;\n\n const index = this.items.findIndex(i => i.value === this.value);\n\n if (index === -1) {\n this.hasValue = false;\n this.gliderWidth = 0;\n return;\n }\n\n const activeElement = this.itemElements.get(index)?.nativeElement;\n\n if (activeElement) {\n this.hasValue = true;\n this.gliderLeft = activeElement.offsetLeft;\n this.gliderWidth = activeElement.offsetWidth;\n this.cdr.detectChanges();\n }\n }\n\n // CVA Implementation\n writeValue(val: any): void {\n this.value = val;\n setTimeout(() => this.updateGlider());\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n\n/**\n * Back-compat alias: the class was renamed to match its `tolle-segment` selector.\n *\n * A re-export binding rather than `const SegmentedComponent = SegmentComponent`,\n * because Angular's AOT compiler resolves an aliased export straight back to the\n * class declaration — so it still works in a consumer's `imports: []` array.\n *\n * @deprecated Use `SegmentComponent`. This alias will be removed in a future major.\n */\nexport { SegmentComponent as SegmentedComponent };\n"
13
+ "content": "import {\n Component,\n Input,\n forwardRef,\n ElementRef,\n ViewChildren,\n QueryList,\n AfterViewInit,\n ChangeDetectorRef,\n OnChanges,\n OnDestroy,\n SimpleChanges,\n TemplateRef,\n ViewChild,\n} from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { ControlValueAccessor, NG_VALUE_ACCESSOR, FormsModule } from '@angular/forms';\nimport { cn } from './utils/cn';\n\nexport type SegmentItem = {\n label: string;\n value: any;\n disabled?: boolean;\n icon?: string; // Added: Optional icon class (e.g., 'ri-home-line')\n class?: string; // Added: Custom class for specific item wrapper\n data?: any; // Added: Extra data payload for custom templates\n}\n\n@Component({\n selector: 'tolle-segment',\n standalone: true,\n imports: [CommonModule, FormsModule],\n providers: [\n {\n provide: NG_VALUE_ACCESSOR,\n useExisting: forwardRef(() => SegmentComponent),\n multi: true\n }\n ],\n template: `\n <div\n #container\n [class]=\"cn(\n 'relative flex items-center p-1 bg-muted rounded-lg select-none w-full gap-1',\n class\n )\"\n role=\"radiogroup\"\n (keydown)=\"onKeydown($event)\"\n >\n <div\n class=\"absolute top-1 bottom-1 bg-primary shadow-sm rounded-md transition-all duration-300 ease-tolle\"\n [style.left.px]=\"gliderLeft\"\n [style.width.px]=\"gliderWidth\"\n [class.opacity-0]=\"!hasValue\"\n ></div>\n\n <button\n *ngFor=\"let item of items\"\n #itemEls\n type=\"button\"\n role=\"radio\"\n [disabled]=\"item.disabled || disabled\"\n [attr.aria-checked]=\"value === item.value\"\n [attr.tabindex]=\"getTabIndex(item)\"\n (click)=\"select(item.value)\"\n [class]=\"cn(\n 'relative z-10 flex-1 px-3 py-1.5 text-sm font-medium transition-colors duration-200 rounded-md text-center focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',\n 'flex items-center justify-center gap-2',\n value === item.value\n ? 'text-primary-foreground'\n : 'text-muted-foreground hover:text-foreground/70',\n item.disabled && 'opacity-50 cursor-not-allowed',\n item.class\n )\"\n >\n <ng-container *ngIf=\"itemTemplate; else defaultContent\">\n <ng-container *ngTemplateOutlet=\"itemTemplate; context: { $implicit: item, selected: value === item.value }\">\n </ng-container>\n </ng-container>\n\n <ng-template #defaultContent>\n <i *ngIf=\"item.icon\" [class]=\"item.icon\"></i>\n <span class=\"truncate\">{{ item.label }}</span>\n </ng-template>\n </button>\n </div>\n `,\n styles: [`\n :host {\n display: block;\n }\n `]\n})\nexport class SegmentComponent implements ControlValueAccessor, AfterViewInit, OnChanges, OnDestroy {\n @Input() items: SegmentItem[] = [];\n @Input() class = '';\n @Input() disabled = false;\n @Input() itemTemplate?: TemplateRef<any>; // Allow custom content\n\n value: any = null;\n gliderLeft = 0;\n gliderWidth = 0;\n hasValue = false;\n\n @ViewChild('container') containerEl!: ElementRef<HTMLElement>;\n @ViewChildren('itemEls') itemElements!: QueryList<ElementRef<HTMLElement>>;\n\n private resizeObserver?: ResizeObserver;\n\n onChange: any = () => { };\n onTouched: any = () => { };\n\n constructor(private cdr: ChangeDetectorRef) { }\n\n ngAfterViewInit() {\n setTimeout(() => this.updateGlider());\n\n if (typeof ResizeObserver !== 'undefined' && this.containerEl) {\n this.resizeObserver = new ResizeObserver(() => {\n this.updateGlider();\n });\n this.resizeObserver.observe(this.containerEl.nativeElement);\n }\n }\n\n ngOnDestroy() {\n if (this.resizeObserver) {\n this.resizeObserver.disconnect();\n }\n }\n\n ngOnChanges(changes: SimpleChanges) {\n // Recalculate if items change or if value changes externally\n if (changes['items'] && !changes['items'].firstChange) {\n setTimeout(() => this.updateGlider());\n }\n }\n\n select(val: any) {\n if (this.disabled) return;\n const item = this.items.find(i => i.value === val);\n if (item?.disabled) return;\n\n this.value = val;\n this.onChange(val);\n this.onTouched();\n this.updateGlider();\n }\n\n /** True when the current value matches one of the items. */\n private hasSelectedValue(): boolean {\n return this.items.some(i => i.value === this.value);\n }\n\n private firstEnabledItem(): SegmentItem | undefined {\n if (this.disabled) return undefined;\n return this.items.find(i => !i.disabled);\n }\n\n /**\n * Roving tabindex: the selected item is tab-reachable, or - when nothing is\n * selected - the first enabled item. All other items get -1.\n */\n getTabIndex(item: SegmentItem): number {\n if (this.disabled || item.disabled) return -1;\n if (this.value === item.value) return 0;\n if (!this.hasSelectedValue() && this.firstEnabledItem() === item) return 0;\n return -1;\n }\n\n /**\n * WAI-ARIA radio group keyboard nav: Arrow Left/Right (and Home/End) move\n * selection + focus across the enabled items, wrapping and skipping disabled.\n */\n onKeydown(event: KeyboardEvent) {\n if (this.disabled) return;\n\n const key = event.key;\n const forward = key === 'ArrowRight';\n const backward = key === 'ArrowLeft';\n const home = key === 'Home';\n const end = key === 'End';\n if (!forward && !backward && !home && !end) return;\n\n const enabled = this.items.map((it, i) => ({ it, i })).filter(x => !x.it.disabled);\n if (!enabled.length) return;\n\n event.preventDefault();\n\n const buttons = this.itemElements?.toArray().map(r => r.nativeElement) ?? [];\n const focusedIndex = buttons.indexOf(event.target as HTMLElement);\n let pos = enabled.findIndex(x => x.i === focusedIndex);\n if (pos < 0) pos = enabled.findIndex(x => x.it.value === this.value);\n\n let targetPos: number;\n if (home) {\n targetPos = 0;\n } else if (end) {\n targetPos = enabled.length - 1;\n } else if (forward) {\n targetPos = pos < 0 ? 0 : (pos + 1) % enabled.length;\n } else {\n targetPos = pos < 0 ? enabled.length - 1 : (pos - 1 + enabled.length) % enabled.length;\n }\n\n const target = enabled[targetPos];\n this.select(target.it.value);\n this.itemElements.get(target.i)?.nativeElement.focus();\n }\n\n updateGlider() {\n if (!this.itemElements || !this.items.length) return;\n\n const index = this.items.findIndex(i => i.value === this.value);\n\n if (index === -1) {\n this.hasValue = false;\n this.gliderWidth = 0;\n return;\n }\n\n const activeElement = this.itemElements.get(index)?.nativeElement;\n\n if (activeElement) {\n this.hasValue = true;\n this.gliderLeft = activeElement.offsetLeft;\n this.gliderWidth = activeElement.offsetWidth;\n this.cdr.detectChanges();\n }\n }\n\n // CVA Implementation\n writeValue(val: any): void {\n this.value = val;\n setTimeout(() => this.updateGlider());\n }\n\n registerOnChange(fn: any): void { this.onChange = fn; }\n registerOnTouched(fn: any): void { this.onTouched = fn; }\n\n setDisabledState(isDisabled: boolean): void {\n this.disabled = isDisabled;\n this.cdr.markForCheck();\n }\n\n protected cn = cn;\n}\n\n/**\n * Back-compat alias: the class was renamed to match its `tolle-segment` selector.\n *\n * A re-export binding rather than `const SegmentedComponent = SegmentComponent`,\n * because Angular's AOT compiler resolves an aliased export straight back to the\n * class declaration — so it still works in a consumer's `imports: []` array.\n *\n * @deprecated Use `SegmentComponent`. This alias will be removed in a future major.\n */\nexport { SegmentComponent as SegmentedComponent };\n"
14
14
  },
15
15
  {
16
16
  "path": "utils/cn.ts",
@@ -0,0 +1,25 @@
1
+ {
2
+ "name": "tracker",
3
+ "type": "registry:ui",
4
+ "title": "Tracker",
5
+ "description": "Tracker component.",
6
+ "category": "components",
7
+ "registryDependencies": [
8
+ "tooltip"
9
+ ],
10
+ "dependencies": [
11
+ "@floating-ui/dom"
12
+ ],
13
+ "files": [
14
+ {
15
+ "path": "tracker.component.ts",
16
+ "target": "ui/tracker.component.ts",
17
+ "content": "import { Component, Input, ChangeDetectionStrategy, ChangeDetectorRef, OnChanges, inject } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { cn } from './utils/cn';\nimport { TooltipDirective } from './tooltip.directive';\n\n/** Health of one period in the strip. Maps to the theme's semantic status tokens, not the chart palette. */\nexport type TrackerStatus = 'success' | 'warning' | 'error' | 'neutral';\n\n/** One block: its health, an optional per-block colour override, and the tooltip naming it. */\nexport interface TrackerBlock {\n status?: TrackerStatus;\n color?: string;\n tooltip?: string;\n}\n\nconst STATUS_COLOR: Record<TrackerStatus, string> = {\n success: 'rgb(var(--success))',\n warning: 'rgb(var(--warning))',\n error: 'rgb(var(--destructive))',\n neutral: 'rgb(var(--muted))',\n};\n\n/**\n * A row of equal-width blocks, one per period — an uptime or activity strip.\n * Each block is a real `<button>` so its tooltip is reachable by keyboard and\n * has a screen-reader name of its own, not just a hover affordance.\n * @new\n */\n@Component({\n selector: 'tolle-tracker',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule, TooltipDirective],\n changeDetection: ChangeDetectionStrategy.OnPush,\n template: `\n <div [class]=\"computedClass\" role=\"group\" [attr.aria-label]=\"ariaLabel || null\">\n <button\n *ngFor=\"let block of data; let i = index; trackBy: trackByIndex\"\n type=\"button\"\n class=\"flex-1 rounded-[2px] transition-opacity hover:opacity-80 focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring\"\n [style.height.px]=\"blockHeight\"\n [style.background]=\"colorFor(block)\"\n [tolleTooltip]=\"block.tooltip || ''\"\n [attr.aria-label]=\"block.tooltip || 'Period ' + (i + 1)\"\n ></button>\n </div>\n `,\n})\nexport class TrackerComponent implements OnChanges {\n /**\n * Angular writes a bound `class` input through its styling path, which does\n * not mark an OnPush component dirty — without this hook the component keeps\n * rendering the class it was born with.\n */\n ngOnChanges(): void {\n this.cdr.markForCheck();\n }\n\n /** Identity for `*ngFor`, so a redraw patches nodes instead of replacing them. */\n trackByIndex = (index: number): number => index;\n\n /** One block per period, in chronological order. @default [] */\n @Input() data: TrackerBlock[] = [];\n /** Height of each block in px. @default 32 */\n @Input() blockHeight = 32;\n /** Accessible name for the whole strip. @default '' */\n @Input() ariaLabel = '';\n /** Extra Tailwind classes merged onto the strip via `cn()` (last-wins). */\n @Input() class = '';\n\n private readonly cdr = inject(ChangeDetectorRef);\n\n colorFor(block: TrackerBlock): string {\n return block.color || STATUS_COLOR[block.status ?? 'neutral'];\n }\n\n get computedClass() {\n return cn('flex w-full items-stretch gap-0.5', this.class);\n }\n}\n"
18
+ },
19
+ {
20
+ "path": "utils/cn.ts",
21
+ "target": "ui/utils/cn.ts",
22
+ "content": "import { clsx, type ClassValue } from 'clsx';\nimport { twMerge } from 'tailwind-merge';\n\nexport function cn(...inputs: ClassValue[]) {\n return twMerge(clsx(inputs));\n}\n"
23
+ }
24
+ ]
25
+ }
@@ -213,6 +213,26 @@
213
213
  "class-variance-authority"
214
214
  ]
215
215
  },
216
+ {
217
+ "name": "bar-list",
218
+ "type": "registry:ui",
219
+ "title": "Bar List",
220
+ "description": "Bar List component.",
221
+ "category": "components",
222
+ "isNew": true,
223
+ "files": [
224
+ {
225
+ "path": "bar-list.component.ts",
226
+ "type": "registry:ui"
227
+ },
228
+ {
229
+ "path": "utils/cn.ts",
230
+ "type": "registry:ui"
231
+ }
232
+ ],
233
+ "registryDependencies": [],
234
+ "dependencies": []
235
+ },
216
236
  {
217
237
  "name": "breadcrumb",
218
238
  "type": "registry:ui",
@@ -396,6 +416,26 @@
396
416
  "embla-carousel"
397
417
  ]
398
418
  },
419
+ {
420
+ "name": "category-bar",
421
+ "type": "registry:ui",
422
+ "title": "Category Bar",
423
+ "description": "Category Bar component.",
424
+ "category": "components",
425
+ "isNew": true,
426
+ "files": [
427
+ {
428
+ "path": "category-bar.component.ts",
429
+ "type": "registry:ui"
430
+ },
431
+ {
432
+ "path": "utils/cn.ts",
433
+ "type": "registry:ui"
434
+ }
435
+ ],
436
+ "registryDependencies": [],
437
+ "dependencies": []
438
+ },
399
439
  {
400
440
  "name": "chain-of-thought",
401
441
  "type": "registry:ui",
@@ -472,6 +512,34 @@
472
512
  "class-variance-authority"
473
513
  ]
474
514
  },
515
+ {
516
+ "name": "chart-spark",
517
+ "type": "registry:ui",
518
+ "title": "Chart Spark",
519
+ "description": "Chart Spark component.",
520
+ "category": "data",
521
+ "isNew": true,
522
+ "files": [
523
+ {
524
+ "path": "chart-spark.component.ts",
525
+ "type": "registry:ui"
526
+ },
527
+ {
528
+ "path": "chart.service.ts",
529
+ "type": "registry:ui"
530
+ },
531
+ {
532
+ "path": "utils/cn.ts",
533
+ "type": "registry:ui"
534
+ }
535
+ ],
536
+ "registryDependencies": [
537
+ "chart"
538
+ ],
539
+ "dependencies": [
540
+ "class-variance-authority"
541
+ ]
542
+ },
475
543
  {
476
544
  "name": "checkbox",
477
545
  "type": "registry:ui",
@@ -1595,6 +1663,26 @@
1595
1663
  "registryDependencies": [],
1596
1664
  "dependencies": []
1597
1665
  },
1666
+ {
1667
+ "name": "progress-circle",
1668
+ "type": "registry:ui",
1669
+ "title": "Progress Circle",
1670
+ "description": "Progress Circle component.",
1671
+ "category": "feedback",
1672
+ "isNew": true,
1673
+ "files": [
1674
+ {
1675
+ "path": "progress-circle.component.ts",
1676
+ "type": "registry:ui"
1677
+ },
1678
+ {
1679
+ "path": "utils/cn.ts",
1680
+ "type": "registry:ui"
1681
+ }
1682
+ ],
1683
+ "registryDependencies": [],
1684
+ "dependencies": []
1685
+ },
1598
1686
  {
1599
1687
  "name": "prompt-input",
1600
1688
  "type": "registry:ui",
@@ -2407,6 +2495,30 @@
2407
2495
  "@floating-ui/dom"
2408
2496
  ]
2409
2497
  },
2498
+ {
2499
+ "name": "tracker",
2500
+ "type": "registry:ui",
2501
+ "title": "Tracker",
2502
+ "description": "Tracker component.",
2503
+ "category": "components",
2504
+ "isNew": true,
2505
+ "files": [
2506
+ {
2507
+ "path": "tracker.component.ts",
2508
+ "type": "registry:ui"
2509
+ },
2510
+ {
2511
+ "path": "utils/cn.ts",
2512
+ "type": "registry:ui"
2513
+ }
2514
+ ],
2515
+ "registryDependencies": [
2516
+ "tooltip"
2517
+ ],
2518
+ "dependencies": [
2519
+ "@floating-ui/dom"
2520
+ ]
2521
+ },
2410
2522
  {
2411
2523
  "name": "typography",
2412
2524
  "type": "registry:ui",
package/theme.css CHANGED
@@ -456,7 +456,19 @@ a:hover {
456
456
  rgb(var(--muted-foreground)) 60%,
457
457
  rgb(var(--muted-foreground)) 100%);
458
458
  background-size: 200% 100%;
459
- background-repeat: no-repeat;
459
+ /*
460
+ * `no-repeat` was the actual bug: the gradient image is only 200% wide, so
461
+ * as `background-position` swept it across the text, whatever fraction of
462
+ * the text the (finite) image no longer reached had nothing behind the
463
+ * transparent fill — not dimmed, genuinely invisible. That showed up two
464
+ * ways: fully blank for a stretch once the image swept completely past
465
+ * the text, and, on every sweep, part of the text (whichever side the
466
+ * image had already vacated) rendering as if cut off. The gradient's
467
+ * first and last stops both resolve to the same muted-foreground colour,
468
+ * so tiling it seamlessly loops — repeating keeps the text fully covered,
469
+ * always at least muted, for the entire animation.
470
+ */
471
+ background-repeat: repeat;
460
472
  -webkit-background-clip: text;
461
473
  background-clip: text;
462
474
  color: transparent;
@@ -466,11 +478,11 @@ a:hover {
466
478
 
467
479
  @keyframes tolle-shimmer {
468
480
  from {
469
- background-position: 200% 0;
481
+ background-position: 100% 0;
470
482
  }
471
483
 
472
484
  to {
473
- background-position: -200% 0;
485
+ background-position: -100% 0;
474
486
  }
475
487
  }
476
488