@tolle_/tolle-ui 18.2.30 → 18.3.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.
@@ -3,6 +3,11 @@ import * as i0 from "@angular/core";
3
3
  export declare class AlertDialogDynamicComponent {
4
4
  config: AlertDialogConfig;
5
5
  dialogRef: AlertDialogRef;
6
+ /**
7
+ * Set by `AlertDialogService` once the dialog's result is ready, so the
8
+ * content panel plays its exit animation before the overlay is torn down.
9
+ */
10
+ closing: boolean;
6
11
  onOpenChange(open: boolean): void;
7
12
  close(result: boolean): void;
8
13
  static ɵfac: i0.ɵɵFactoryDeclaration<AlertDialogDynamicComponent, never>;
@@ -1,6 +1,14 @@
1
1
  import { EventEmitter, TemplateRef, OnDestroy, OnInit } from '@angular/core';
2
2
  import { AlertDialogSize } from './alert-dialog.types';
3
3
  import * as i0 from "@angular/core";
4
+ /**
5
+ * Safety net if the exit animation's `animationend` never fires — reduced
6
+ * motion, no matching keyframe, or a panel that never rendered — so an
7
+ * alert dialog can never get stuck open forever. Also used by
8
+ * `alert-dialog.service.ts` and `alert-dialog-dynamic.component.ts`, which
9
+ * animate the same content component through a different overlay wiring.
10
+ */
11
+ export declare const CLOSE_FALLBACK_MS = 300;
4
12
  export declare class AlertDialogComponent {
5
13
  set open(val: boolean);
6
14
  openChange: EventEmitter<boolean>;
@@ -25,6 +33,11 @@ export declare class AlertDialogPortalComponent implements OnInit, OnDestroy {
25
33
  private previouslyFocused?;
26
34
  ngOnInit(): void;
27
35
  private show;
36
+ /**
37
+ * Tears the overlay down once its exit animation finishes (or after
38
+ * `CLOSE_FALLBACK_MS`, whichever comes first) instead of instantly, so
39
+ * `data-[state=closed]:animate-out` actually gets a chance to play.
40
+ */
28
41
  private hide;
29
42
  ngOnDestroy(): void;
30
43
  static ɵfac: i0.ɵɵFactoryDeclaration<AlertDialogPortalComponent, never>;
@@ -7,9 +7,14 @@ export declare class ModalRef<R = any> {
7
7
  private stack;
8
8
  private readonly _afterClosed$;
9
9
  afterClosed$: import("rxjs").Observable<R | null | undefined>;
10
+ private readonly _closingSubject;
11
+ /** True once `close()` has been called — drives the panel's exit animation. */
12
+ readonly closing$: import("rxjs").Observable<boolean>;
10
13
  constructor(overlay: OverlayRef, modal: Modal, stack: ModalStackService);
11
14
  /**
12
- * Closes the modal instantly.
15
+ * Closes the modal. Signals the panel and backdrop to play their exit
16
+ * animation, then tears down the overlay once it finishes (or after
17
+ * `CLOSE_FALLBACK_MS`, whichever comes first).
13
18
  * @param result Data to pass back to the caller
14
19
  */
15
20
  close(result?: R): void;
@@ -1,20 +1,23 @@
1
- import { OnInit, TemplateRef, Type } from '@angular/core';
1
+ import { OnDestroy, OnInit, TemplateRef, Type } from '@angular/core';
2
2
  import { ModalRef } from './modal-ref';
3
3
  import { cn } from './utils/cn';
4
4
  import * as i0 from "@angular/core";
5
- export declare class ModalComponent implements OnInit {
5
+ export declare class ModalComponent implements OnInit, OnDestroy {
6
6
  ref: ModalRef;
7
+ private readonly cdr;
8
+ private readonly subscription;
7
9
  contentType: 'template' | 'string' | 'component';
8
10
  content: any;
9
11
  modalClasses: string;
12
+ /** True while the exit animation plays, just before the overlay is torn down. */
13
+ closing: boolean;
10
14
  /** Stable, per-instance ids used to wire dialog ARIA relationships. */
11
15
  private readonly _uid;
12
16
  readonly titleId: string;
13
17
  readonly descId: string;
14
18
  constructor(ref: ModalRef);
15
19
  ngOnInit(): void;
16
- /** Whether the auto-rendered header (title and/or close button) is shown. */
17
- get hasHeader(): boolean;
20
+ ngOnDestroy(): void;
18
21
  private getModalSizeCss;
19
22
  private determineContentType;
20
23
  get asTemplate(): TemplateRef<any>;
package/lib/modal.d.ts CHANGED
@@ -1,5 +1,10 @@
1
1
  import { TemplateRef, Type } from "@angular/core";
2
- export declare class Modal<T = any> {
2
+ /**
3
+ * Configuration for `ModalService.open()`. Always passed as a plain object
4
+ * literal, so unset fields fall back to `MODAL_DEFAULTS` in `modal.service.ts`
5
+ * rather than a class field initializer — those never run for a literal.
6
+ */
7
+ export interface Modal<T = any> {
3
8
  /** The content to display (String, Component, or Template) */
4
9
  content: string | Type<any> | TemplateRef<any>;
5
10
  /** Optional title for the standard header */
@@ -7,8 +12,8 @@ export declare class Modal<T = any> {
7
12
  /** * Predefined size scale.
8
13
  * - xs: 320px (Mobile alerts)
9
14
  * - sm: 425px (Standard dialogs)
10
- * - default: 544px (Forms)
11
- * - lg: 90% / 1024px (Data tables)
15
+ * - default: 512px (Forms)
16
+ * - lg: 1024px (Data tables)
12
17
  * - xl: 1280px (Large forms, dashboards)
13
18
  * - fullscreen: 100vw/100vh (Complex workflows)
14
19
  */
@@ -27,5 +32,6 @@ export declare class Modal<T = any> {
27
32
  * Accessed via `let-val` in the HTML.
28
33
  */
29
34
  context?: T;
35
+ /** Whether the built-in corner close button renders. Defaults to true. */
30
36
  showCloseButton?: boolean;
31
37
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tolle_/tolle-ui",
3
- "version": "18.2.30",
3
+ "version": "18.3.0",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -35,7 +35,8 @@
35
35
  "tailwind-merge": "^3.4.0",
36
36
  "clsx": "^2.1.1",
37
37
  "date-fns": "^4.1.0",
38
- "embla-carousel": "^8.5.2"
38
+ "embla-carousel": "^8.5.2",
39
+ "tailwindcss-animate": "^1.0.7"
39
40
  },
40
41
  "dependencies": {
41
42
  "tslib": "^2.3.0"
package/preset.js CHANGED
@@ -86,6 +86,12 @@ module.exports = {
86
86
  md: 'calc(var(--radius) - 2px)',
87
87
  sm: 'calc(var(--radius) - 4px)',
88
88
  },
89
+ transitionTimingFunction: {
90
+ // Named so components reference `ease-tolle` instead of an inline
91
+ // `ease-[cubic-bezier(...)]` arbitrary value — Tailwind 3.4 flags
92
+ // the comma-separated arbitrary form as an ambiguous utility match.
93
+ tolle: 'cubic-bezier(0.2, 0.0, 0.2, 1)',
94
+ },
89
95
  boxShadow: {
90
96
  sm: 'var(--shadow-sm)',
91
97
  DEFAULT: 'var(--shadow)',
@@ -94,5 +100,5 @@ module.exports = {
94
100
  },
95
101
  },
96
102
  },
97
- plugins: [],
103
+ plugins: [require('tailwindcss-animate')],
98
104
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "package": "@tolle_/tolle-ui",
3
- "version": "18.2.30",
3
+ "version": "18.3.0",
4
4
  "registry": "r/{name}.json",
5
5
  "components": [
6
6
  {
@@ -15,7 +15,7 @@
15
15
  {
16
16
  "path": "alert-dialog-dynamic.component.ts",
17
17
  "target": "ui/alert-dialog-dynamic.component.ts",
18
- "content": "import { Component, Inject, inject, OnInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport {\n AlertDialogComponent,\n AlertDialogContentComponent,\n AlertDialogHeaderComponent,\n AlertDialogTitleComponent,\n AlertDialogDescriptionComponent,\n AlertDialogFooterComponent,\n AlertDialogCancelComponent,\n AlertDialogActionComponent\n} from './alert-dialog.component';\nimport { ButtonComponent } from './button.component';\nimport { AlertDialogConfig, AlertDialogRef } from './alert-dialog.types';\n\n@Component({\n selector: 'tolle-alert-dialog-dynamic',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [\n CommonModule,\n AlertDialogComponent,\n AlertDialogContentComponent,\n AlertDialogHeaderComponent,\n AlertDialogTitleComponent,\n AlertDialogDescriptionComponent,\n AlertDialogFooterComponent,\n AlertDialogCancelComponent,\n AlertDialogActionComponent,\n ButtonComponent\n ],\n template: `\n <tolle-alert-dialog [open]=\"true\" (openChange)=\"onOpenChange($event)\">\n <tolle-alert-dialog-content [size]=\"config.size || 'lg'\">\n <tolle-alert-dialog-header>\n <tolle-alert-dialog-title>{{ config.title }}</tolle-alert-dialog-title>\n <tolle-alert-dialog-description>\n {{ config.description }}\n </tolle-alert-dialog-description>\n </tolle-alert-dialog-header>\n <tolle-alert-dialog-footer>\n <tolle-alert-dialog-cancel (click)=\"close(false)\">\n <tolle-button variant=\"outline\">{{ config.cancelText || 'Cancel' }}</tolle-button>\n </tolle-alert-dialog-cancel>\n <tolle-alert-dialog-action (click)=\"close(true)\">\n <tolle-button [variant]=\"config.variant === 'destructive' ? 'destructive' : 'default'\">\n {{ config.actionText || 'Continue' }}\n </tolle-button>\n </tolle-alert-dialog-action>\n </tolle-alert-dialog-footer>\n </tolle-alert-dialog-content>\n </tolle-alert-dialog>\n `\n})\nexport class AlertDialogDynamicComponent {\n config!: AlertDialogConfig;\n dialogRef!: AlertDialogRef;\n\n onOpenChange(open: boolean) {\n if (!open) {\n this.close(false);\n }\n }\n\n close(result: boolean) {\n this.dialogRef.close(result);\n }\n}\n"
18
+ "content": "import { Component, Inject, inject, OnInit } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport {\n AlertDialogComponent,\n AlertDialogContentComponent,\n AlertDialogHeaderComponent,\n AlertDialogTitleComponent,\n AlertDialogDescriptionComponent,\n AlertDialogFooterComponent,\n AlertDialogCancelComponent,\n AlertDialogActionComponent\n} from './alert-dialog.component';\nimport { ButtonComponent } from './button.component';\nimport { AlertDialogConfig, AlertDialogRef } from './alert-dialog.types';\n\n@Component({\n selector: 'tolle-alert-dialog-dynamic',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [\n CommonModule,\n AlertDialogComponent,\n AlertDialogContentComponent,\n AlertDialogHeaderComponent,\n AlertDialogTitleComponent,\n AlertDialogDescriptionComponent,\n AlertDialogFooterComponent,\n AlertDialogCancelComponent,\n AlertDialogActionComponent,\n ButtonComponent\n ],\n template: `\n <tolle-alert-dialog [open]=\"!closing\" (openChange)=\"onOpenChange($event)\">\n <tolle-alert-dialog-content [size]=\"config.size || 'lg'\">\n <tolle-alert-dialog-header>\n <tolle-alert-dialog-title>{{ config.title }}</tolle-alert-dialog-title>\n <tolle-alert-dialog-description>\n {{ config.description }}\n </tolle-alert-dialog-description>\n </tolle-alert-dialog-header>\n <tolle-alert-dialog-footer>\n <tolle-alert-dialog-cancel (click)=\"close(false)\">\n <tolle-button variant=\"outline\">{{ config.cancelText || 'Cancel' }}</tolle-button>\n </tolle-alert-dialog-cancel>\n <tolle-alert-dialog-action (click)=\"close(true)\">\n <tolle-button [variant]=\"config.variant === 'destructive' ? 'destructive' : 'default'\">\n {{ config.actionText || 'Continue' }}\n </tolle-button>\n </tolle-alert-dialog-action>\n </tolle-alert-dialog-footer>\n </tolle-alert-dialog-content>\n </tolle-alert-dialog>\n `\n})\nexport class AlertDialogDynamicComponent {\n config!: AlertDialogConfig;\n dialogRef!: AlertDialogRef;\n\n /**\n * Set by `AlertDialogService` once the dialog's result is ready, so the\n * content panel plays its exit animation before the overlay is torn down.\n */\n closing = false;\n\n onOpenChange(open: boolean) {\n if (!open) {\n this.close(false);\n }\n }\n\n close(result: boolean) {\n this.dialogRef.close(result);\n }\n}\n"
19
19
  },
20
20
  {
21
21
  "path": "alert-dialog.types.ts",
@@ -10,7 +10,7 @@
10
10
  {
11
11
  "path": "alert-dialog.component.ts",
12
12
  "target": "ui/alert-dialog.component.ts",
13
- "content": "import { Component, Input, Output, EventEmitter, HostListener, Injectable, inject, TemplateRef, ViewChild, ViewContainerRef, OnDestroy, OnInit, ContentChild, forwardRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Overlay, OverlayRef, OverlayConfig } from '@angular/cdk/overlay';\nimport { TemplatePortal, ComponentPortal } from '@angular/cdk/portal';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { cn } from './utils/cn';\nimport { BehaviorSubject } from 'rxjs';\nimport { AlertDialogSize } from './alert-dialog.types';\n\n@Injectable()\nclass AlertDialogInternalService {\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-alert-dialog',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [AlertDialogInternalService],\n template: `<ng-content></ng-content>`\n})\nexport class AlertDialogComponent {\n @Input() set open(val: boolean) {\n this.alertDialogService.setOpen(val);\n }\n @Output() openChange = new EventEmitter<boolean>();\n\n private alertDialogService = inject(AlertDialogInternalService);\n\n constructor() {\n this.alertDialogService.open$.subscribe(val => {\n this.openChange.emit(val);\n });\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n '(click)': 'onOpen()'\n }\n})\nexport class AlertDialogTriggerComponent {\n private alertDialogService = inject(AlertDialogInternalService);\n\n onOpen() {\n this.alertDialogService.setOpen(true);\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-portal',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <ng-template #portalContent>\n <ng-content></ng-content>\n </ng-template>\n `\n})\nexport class AlertDialogPortalComponent implements OnInit, OnDestroy {\n @ViewChild('portalContent', { static: true }) portalContent!: TemplateRef<any>;\n private alertDialogService = inject(AlertDialogInternalService);\n private overlay = inject(Overlay);\n private viewContainerRef = inject(ViewContainerRef);\n private overlayRef?: OverlayRef;\n /** Element focused before the dialog opened, restored on close. */\n private previouslyFocused?: HTMLElement | null;\n\n ngOnInit() {\n this.alertDialogService.open$.subscribe(open => {\n if (open) {\n this.show();\n } else {\n this.hide();\n }\n });\n }\n\n private show() {\n if (this.overlayRef) return;\n\n // Remember the trigger so focus can be restored on close.\n this.previouslyFocused = document.activeElement as HTMLElement | null;\n\n const config = new OverlayConfig({\n hasBackdrop: true,\n backdropClass: ['cdk-overlay-backdrop', 'bg-black/80', 'backdrop-blur-sm', 'data-[state=open]:animate-in', 'data-[state=closed]:animate-out', 'data-[state=closed]:fade-out-0', 'data-[state=open]:fade-in-0'],\n positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),\n scrollStrategy: this.overlay.scrollStrategies.block()\n });\n\n this.overlayRef = this.overlay.create(config);\n\n // Alert-dialog semantics: backdrop clicks must NOT dismiss.\n // Escape still closes the dialog.\n this.overlayRef.keydownEvents().subscribe((event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n event.preventDefault();\n this.alertDialogService.setOpen(false);\n }\n });\n\n const portal = new TemplatePortal(this.portalContent, this.viewContainerRef);\n this.overlayRef.attach(portal);\n }\n\n private hide() {\n if (this.overlayRef) {\n this.overlayRef.detach();\n this.overlayRef.dispose();\n this.overlayRef = undefined;\n // Restore focus to the trigger.\n this.previouslyFocused?.focus?.();\n }\n }\n\n ngOnDestroy() {\n this.hide();\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-content',\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `<div [class]=\"computedClass\" [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n role=\"alertdialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"titleCmp?.id || null\"\n [attr.aria-describedby]=\"descriptionCmp?.id || null\"\n cdkTrapFocus cdkTrapFocusAutoCapture><ng-content></ng-content></div>`,\n host: {\n '[class]': '\"contents\"'\n }\n})\nexport class AlertDialogContentComponent {\n @Input() class: string = '';\n @Input() size: AlertDialogSize = 'lg';\n\n /** Projected title/description used to build the dialog's accessible name. */\n @ContentChild(forwardRef(() => AlertDialogTitleComponent)) titleCmp?: AlertDialogTitleComponent;\n @ContentChild(forwardRef(() => AlertDialogDescriptionComponent)) descriptionCmp?: AlertDialogDescriptionComponent;\n\n private alertDialogService = inject(AlertDialogInternalService);\n isOpen = false;\n\n constructor() {\n this.alertDialogService.open$.subscribe(val => {\n this.isOpen = val;\n });\n }\n\n get computedClass() {\n return cn(\n \"fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] z-50 grid w-full gap-4 border border-input bg-background p-6 shadow-lg data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] rounded-lg\",\n {\n 'max-w-xs': this.size === 'xs',\n 'max-w-sm': this.size === 'sm',\n 'max-w-md': this.size === 'md',\n 'max-w-lg': this.size === 'lg',\n 'max-w-xl': this.size === 'xl',\n 'max-w-2xl': this.size === '2xl',\n 'max-w-full': this.size === 'full',\n 'max-w-fit': this.size === 'fit',\n },\n this.class\n );\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-header',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass' }\n})\nexport class AlertDialogHeaderComponent {\n @Input() class: string = '';\n get computedClass() { return cn(\"flex flex-col space-y-2 text-center sm:text-left\", this.class); }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-footer',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass' }\n})\nexport class AlertDialogFooterComponent {\n @Input() class: string = '';\n get computedClass() { return cn(\"flex flex-row justify-end space-x-2\", this.class); }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-title',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass', '[attr.id]': 'id' }\n})\nexport class AlertDialogTitleComponent {\n @Input() class: string = '';\n /** Stable id referenced by the dialog's `aria-labelledby`. */\n readonly id = `tolle-alert-dialog-title-${Math.random().toString(36).substr(2, 9)}`;\n get computedClass() { return cn(\"text-lg font-semibold text-foreground\", this.class); }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-description',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass', '[attr.id]': 'id' }\n})\nexport class AlertDialogDescriptionComponent {\n @Input() class: string = '';\n /** Stable id referenced by the dialog's `aria-describedby`. */\n readonly id = `tolle-alert-dialog-description-${Math.random().toString(36).substr(2, 9)}`;\n get computedClass() { return cn(\"text-sm text-muted-foreground\", this.class); }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-action',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`\n})\nexport class AlertDialogActionComponent {\n private alertDialogService = inject(AlertDialogInternalService, { optional: true });\n\n /** Fires before the dialog closes, so consumers can run the confirmed action. */\n @Output() confirmed = new EventEmitter<MouseEvent>();\n\n @HostListener('click', ['$event'])\n handleClick(event: MouseEvent) {\n this.confirmed.emit(event);\n this.alertDialogService?.setOpen(false);\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-cancel',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`\n})\nexport class AlertDialogCancelComponent {\n private alertDialogService = inject(AlertDialogInternalService, { optional: true });\n\n /** Fires before the dialog closes, so consumers can react to a dismissal. */\n @Output() cancelled = new EventEmitter<MouseEvent>();\n\n @HostListener('click', ['$event'])\n handleClick(event: MouseEvent) {\n this.cancelled.emit(event);\n this.alertDialogService?.setOpen(false);\n }\n}\n"
13
+ "content": "import { Component, Input, Output, EventEmitter, HostListener, Injectable, inject, TemplateRef, ViewChild, ViewContainerRef, OnDestroy, OnInit, ContentChild, forwardRef } from '@angular/core';\nimport { CommonModule } from '@angular/common';\nimport { Overlay, OverlayRef, OverlayConfig } from '@angular/cdk/overlay';\nimport { TemplatePortal, ComponentPortal } from '@angular/cdk/portal';\nimport { A11yModule } from '@angular/cdk/a11y';\nimport { cn } from './utils/cn';\nimport { BehaviorSubject } from 'rxjs';\nimport { AlertDialogSize } from './alert-dialog.types';\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 an\n * alert dialog can never get stuck open forever. Also used by\n * `alert-dialog.service.ts` and `alert-dialog-dynamic.component.ts`, which\n * animate the same content component through a different overlay wiring.\n */\nexport const CLOSE_FALLBACK_MS = 300;\n\n@Injectable()\nclass AlertDialogInternalService {\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-alert-dialog',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n providers: [AlertDialogInternalService],\n template: `<ng-content></ng-content>`\n})\nexport class AlertDialogComponent {\n @Input() set open(val: boolean) {\n this.alertDialogService.setOpen(val);\n }\n @Output() openChange = new EventEmitter<boolean>();\n\n private alertDialogService = inject(AlertDialogInternalService);\n\n constructor() {\n this.alertDialogService.open$.subscribe(val => {\n this.openChange.emit(val);\n });\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-trigger',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: {\n '(click)': 'onOpen()'\n }\n})\nexport class AlertDialogTriggerComponent {\n private alertDialogService = inject(AlertDialogInternalService);\n\n onOpen() {\n this.alertDialogService.setOpen(true);\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-portal',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `\n <ng-template #portalContent>\n <ng-content></ng-content>\n </ng-template>\n `\n})\nexport class AlertDialogPortalComponent implements OnInit, OnDestroy {\n @ViewChild('portalContent', { static: true }) portalContent!: TemplateRef<any>;\n private alertDialogService = inject(AlertDialogInternalService);\n private overlay = inject(Overlay);\n private viewContainerRef = inject(ViewContainerRef);\n private overlayRef?: OverlayRef;\n /** Element focused before the dialog opened, restored on close. */\n private previouslyFocused?: HTMLElement | null;\n\n ngOnInit() {\n this.alertDialogService.open$.subscribe(open => {\n if (open) {\n this.show();\n } else {\n this.hide();\n }\n });\n }\n\n private show() {\n if (this.overlayRef) return;\n\n // Remember the trigger so focus can be restored on close.\n this.previouslyFocused = document.activeElement as HTMLElement | null;\n\n const config = new OverlayConfig({\n hasBackdrop: true,\n backdropClass: ['cdk-overlay-backdrop', 'bg-black/80', 'backdrop-blur-sm', 'data-[state=open]:animate-in', 'data-[state=closed]:animate-out', 'data-[state=closed]:fade-out-0', 'data-[state=open]:fade-in-0'],\n positionStrategy: this.overlay.position().global().centerHorizontally().centerVertically(),\n scrollStrategy: this.overlay.scrollStrategies.block()\n });\n\n this.overlayRef = this.overlay.create(config);\n\n // Alert-dialog semantics: backdrop clicks must NOT dismiss.\n // Escape still closes the dialog.\n this.overlayRef.keydownEvents().subscribe((event: KeyboardEvent) => {\n if (event.key === 'Escape') {\n event.preventDefault();\n this.alertDialogService.setOpen(false);\n }\n });\n\n const portal = new TemplatePortal(this.portalContent, this.viewContainerRef);\n this.overlayRef.attach(portal);\n\n // The backdrop element only exists once attached — setting this any\n // earlier would silently no-op.\n this.overlayRef.backdropElement?.setAttribute('data-state', 'open');\n }\n\n /**\n * Tears the overlay down once its exit animation finishes (or after\n * `CLOSE_FALLBACK_MS`, whichever comes first) instead of instantly, so\n * `data-[state=closed]:animate-out` actually gets a chance to play.\n */\n private hide() {\n const ref = this.overlayRef;\n if (!ref) return;\n\n ref.backdropElement?.setAttribute('data-state', 'closed');\n\n let finished = false;\n const finish = () => {\n if (finished) return;\n finished = true;\n ref.detach();\n ref.dispose();\n if (this.overlayRef === ref) this.overlayRef = undefined;\n // Restore focus to the trigger.\n this.previouslyFocused?.focus?.();\n };\n\n const panel = ref.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 ngOnDestroy() {\n this.hide();\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-content',\n standalone: true,\n imports: [CommonModule, A11yModule],\n template: `<div [class]=\"computedClass\" [attr.data-state]=\"isOpen ? 'open' : 'closed'\"\n role=\"alertdialog\"\n aria-modal=\"true\"\n [attr.aria-labelledby]=\"titleCmp?.id || null\"\n [attr.aria-describedby]=\"descriptionCmp?.id || null\"\n cdkTrapFocus cdkTrapFocusAutoCapture><ng-content></ng-content></div>`,\n host: {\n '[class]': '\"contents\"'\n }\n})\nexport class AlertDialogContentComponent {\n @Input() class: string = '';\n @Input() size: AlertDialogSize = 'lg';\n\n /** Projected title/description used to build the dialog's accessible name. */\n @ContentChild(forwardRef(() => AlertDialogTitleComponent)) titleCmp?: AlertDialogTitleComponent;\n @ContentChild(forwardRef(() => AlertDialogDescriptionComponent)) descriptionCmp?: AlertDialogDescriptionComponent;\n\n private alertDialogService = inject(AlertDialogInternalService);\n isOpen = false;\n\n constructor() {\n this.alertDialogService.open$.subscribe(val => {\n this.isOpen = val;\n });\n }\n\n get computedClass() {\n return cn(\n \"fixed left-[50%] top-[50%] translate-x-[-50%] translate-y-[-50%] z-50 grid w-full gap-4 border border-input bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95 data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] rounded-lg\",\n {\n 'max-w-xs': this.size === 'xs',\n 'max-w-sm': this.size === 'sm',\n 'max-w-md': this.size === 'md',\n 'max-w-lg': this.size === 'lg',\n 'max-w-xl': this.size === 'xl',\n 'max-w-2xl': this.size === '2xl',\n 'max-w-full': this.size === 'full',\n 'max-w-fit': this.size === 'fit',\n },\n this.class\n );\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-header',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass' }\n})\nexport class AlertDialogHeaderComponent {\n @Input() class: string = '';\n get computedClass() { return cn(\"flex flex-col space-y-2 text-center sm:text-left\", this.class); }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-footer',\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass' }\n})\nexport class AlertDialogFooterComponent {\n @Input() class: string = '';\n get computedClass() { return cn(\"flex flex-row justify-end space-x-2\", this.class); }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-title',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass', '[attr.id]': 'id' }\n})\nexport class AlertDialogTitleComponent {\n @Input() class: string = '';\n /** Stable id referenced by the dialog's `aria-labelledby`. */\n readonly id = `tolle-alert-dialog-title-${Math.random().toString(36).substr(2, 9)}`;\n get computedClass() { return cn(\"text-lg font-semibold text-foreground\", this.class); }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-description',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`,\n host: { '[class]': 'computedClass', '[attr.id]': 'id' }\n})\nexport class AlertDialogDescriptionComponent {\n @Input() class: string = '';\n /** Stable id referenced by the dialog's `aria-describedby`. */\n readonly id = `tolle-alert-dialog-description-${Math.random().toString(36).substr(2, 9)}`;\n get computedClass() { return cn(\"text-sm text-muted-foreground\", this.class); }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-action',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`\n})\nexport class AlertDialogActionComponent {\n private alertDialogService = inject(AlertDialogInternalService, { optional: true });\n\n /** Fires before the dialog closes, so consumers can run the confirmed action. */\n @Output() confirmed = new EventEmitter<MouseEvent>();\n\n @HostListener('click', ['$event'])\n handleClick(event: MouseEvent) {\n this.confirmed.emit(event);\n this.alertDialogService?.setOpen(false);\n }\n}\n\n@Component({\n selector: 'tolle-alert-dialog-cancel',\n styles: [':host { display: block; }'],\n standalone: true,\n imports: [CommonModule],\n template: `<ng-content></ng-content>`\n})\nexport class AlertDialogCancelComponent {\n private alertDialogService = inject(AlertDialogInternalService, { optional: true });\n\n /** Fires before the dialog closes, so consumers can react to a dismissal. */\n @Output() cancelled = new EventEmitter<MouseEvent>();\n\n @HostListener('click', ['$event'])\n handleClick(event: MouseEvent) {\n this.cancelled.emit(event);\n this.alertDialogService?.setOpen(false);\n }\n}\n"
14
14
  },
15
15
  {
16
16
  "path": "alert-dialog.types.ts",
@@ -17,7 +17,7 @@
17
17
  {
18
18
  "path": "context-menu.service.ts",
19
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 Object.assign(menuElement.style, {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(x)}px, ${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: '0',\n top: '0',\n transform: `translate(${Math.round(x)}px, ${Math.round(y)}px)`\n });\n }\n\n performAction(actionId: string) {\n this._state.onAction?.(actionId);\n this.close();\n }\n}"
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
21
  }
22
22
  ]
23
23
  }
@@ -17,7 +17,7 @@
17
17
  {
18
18
  "path": "context-menu.service.ts",
19
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 Object.assign(menuElement.style, {\n position: 'fixed',\n left: '0',\n top: '0',\n transform: `translate(${Math.round(x)}px, ${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: '0',\n top: '0',\n transform: `translate(${Math.round(x)}px, ${Math.round(y)}px)`\n });\n }\n\n performAction(actionId: string) {\n this._state.onAction?.(actionId);\n this.close();\n }\n}"
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
21
  },
22
22
  {
23
23
  "path": "utils/cn.ts",
@@ -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",
@@ -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",
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