@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.
- package/esm2022/lib/alert-dialog-dynamic.component.mjs +8 -3
- package/esm2022/lib/alert-dialog.component.mjs +39 -6
- package/esm2022/lib/alert-dialog.service.mjs +38 -6
- package/esm2022/lib/context-menu.service.mjs +11 -7
- package/esm2022/lib/modal-ref.mjs +36 -7
- package/esm2022/lib/modal.component.mjs +42 -28
- package/esm2022/lib/modal.mjs +2 -29
- package/esm2022/lib/modal.service.mjs +26 -4
- package/esm2022/lib/resizable-panel.component.mjs +5 -5
- package/esm2022/lib/segment.component.mjs +3 -3
- package/fesm2022/tolle-ui.mjs +196 -88
- package/fesm2022/tolle-ui.mjs.map +1 -1
- package/lib/alert-dialog-dynamic.component.d.ts +5 -0
- package/lib/alert-dialog.component.d.ts +13 -0
- package/lib/modal-ref.d.ts +6 -1
- package/lib/modal.component.d.ts +7 -4
- package/lib/modal.d.ts +9 -3
- package/package.json +3 -2
- package/preset.js +7 -1
- package/registry/manifest.json +1 -1
- package/registry/r/alert-dialog-dynamic.json +1 -1
- package/registry/r/alert-dialog.json +1 -1
- package/registry/r/context-menu-trigger.json +1 -1
- package/registry/r/context-menu.json +1 -1
- package/registry/r/modal.json +3 -3
- package/registry/r/resizable-panel.json +1 -1
- package/registry/r/segment.json +1 -1
- package/theme.css +15 -3
|
@@ -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>;
|
package/lib/modal-ref.d.ts
CHANGED
|
@@ -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
|
|
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;
|
package/lib/modal.component.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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:
|
|
11
|
-
* - lg:
|
|
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.
|
|
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
|
}
|
package/registry/manifest.json
CHANGED
|
@@ -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]=\"
|
|
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:
|
|
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:
|
|
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",
|
package/registry/r/modal.json
CHANGED
|
@@ -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
|
|
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
|
|
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\
|
|
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",
|
package/registry/r/segment.json
CHANGED
|
@@ -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-
|
|
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
|
-
|
|
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:
|
|
481
|
+
background-position: 100% 0;
|
|
470
482
|
}
|
|
471
483
|
|
|
472
484
|
to {
|
|
473
|
-
background-position: -
|
|
485
|
+
background-position: -100% 0;
|
|
474
486
|
}
|
|
475
487
|
}
|
|
476
488
|
|