import { Component, ElementRef, EventEmitter, HostListener, Input, Output, ViewChild, } from '@angular/core'; export type DialogTone = 'pos' | 'neg' | 'warn' | 'info'; const DIALOG_TONE: Record = { pos: 'var(--success)', neg: 'var(--danger)', warn: 'var(--warning)', info: 'var(--accent)', }; const FOCUSABLE_SEL = 'a[href],button:not([disabled]),textarea:not([disabled]),input:not([disabled]),select:not([disabled]),[tabindex]:not([tabindex="-1"])'; /** Trap Tab focus inside `node`, focus the first focusable, restore focus on cleanup. */ function trapFocus(node: HTMLElement): () => void { const prev = document.activeElement as HTMLElement | null; const list = (): HTMLElement[] => Array.from(node.querySelectorAll(FOCUSABLE_SEL)).filter( (el) => el.offsetWidth > 0 || el.offsetHeight > 0, ); const id = setTimeout(() => (list()[0] ?? node).focus(), 0); const onKey = (e: KeyboardEvent): void => { if (e.key !== 'Tab') return; const els = list(); if (!els.length) { e.preventDefault(); return; } const first = els[0]!; const last = els[els.length - 1]!; if (e.shiftKey && document.activeElement === first) { e.preventDefault(); last.focus(); } else if (!e.shiftKey && document.activeElement === last) { e.preventDefault(); first.focus(); } }; node.addEventListener('keydown', onKey); return () => { clearTimeout(id); node.removeEventListener('keydown', onKey); prev?.focus?.(); }; } /** * A modal dialog. Renders in place — `.ui-overlay` is `position: fixed`, so as * long as the component lives inside `.vsp-root` the overlay covers the viewport * and inherits the theme tokens (no portal needed). Project content into the * default slot for the body, `slot="icon"` for the header icon, and * `slot="footer"` for the footer. */ @Component({ selector: 'vsp-dialog', template: `@if (open) { }`, }) export class VspDialog { @Input() open = false; @Input() title?: string; @Input() desc?: string; @Input() maxWidth = 460; @Input() tone?: DialogTone; @Input() closeOnOverlayClick = true; @Input() closeOnEsc = true; @Output() close = new EventEmitter(); private cleanup: (() => void) | null = null; @ViewChild('panel') set panelRef(el: ElementRef | undefined) { this.cleanup?.(); this.cleanup = el ? trapFocus(el.nativeElement) : null; } get color(): string { return this.tone ? DIALOG_TONE[this.tone] : 'var(--accent)'; } @HostListener('document:keydown.escape') onEsc(): void { if (this.open && this.closeOnEsc) this.close.emit(); } onBackdrop(e: MouseEvent): void { if (this.closeOnOverlayClick && e.target === e.currentTarget) this.close.emit(); } } /** A side drawer. Like `vsp-dialog`, renders in place inside `.vsp-root`. */ @Component({ selector: 'vsp-sheet', template: `@if (open) { }`, }) export class VspSheet { @Input() open = false; @Input() title?: string; @Input() desc?: string; @Input() side: 'right' | 'left' | 'top' | 'bottom' = 'right'; @Input() closeOnOverlayClick = true; @Input() closeOnEsc = true; @Output() close = new EventEmitter(); private cleanup: (() => void) | null = null; @ViewChild('panel') set panelRef(el: ElementRef | undefined) { this.cleanup?.(); this.cleanup = el ? trapFocus(el.nativeElement) : null; } @HostListener('document:keydown.escape') onEsc(): void { if (this.open && this.closeOnEsc) this.close.emit(); } onBackdrop(e: MouseEvent): void { if (this.closeOnOverlayClick && e.target === e.currentTarget) this.close.emit(); } }