import { Component, ElementRef, EventEmitter, forwardRef, HostListener, Input, Output, ViewChild, } from '@angular/core'; import { VspIcon } from './icon.component'; export type AnchoredAlign = 'start' | 'end'; /** Roving arrow-key focus between sibling menu items (one level). */ function menuNav(e: KeyboardEvent): void { const k = e.key; if (k !== 'ArrowDown' && k !== 'ArrowUp' && k !== 'Home' && k !== 'End') return; e.preventDefault(); const parent = (e.currentTarget as HTMLElement).parentElement; if (!parent) return; const els = Array.from( parent.querySelectorAll(':scope > [role^="menuitem"]:not([disabled])'), ); const i = els.indexOf(e.currentTarget as HTMLButtonElement); const n = els.length; const next = k === 'Home' ? els[0] : k === 'End' ? els[n - 1] : k === 'ArrowDown' ? els[(i + 1) % n] : els[(i - 1 + n) % n]; next?.focus(); } /** * Positioning base for menus and popovers: anchors a layer to a trigger. The * layer is `position: fixed`, so it renders in place (inside `.vsp-root`) and is * positioned against the trigger's viewport rect. Click-outside and Escape close * it. Project the trigger with `slot="trigger"`; the default slot is the layer. */ @Component({ selector: 'vsp-anchored', template: ` @if (isOpen) {
}`, }) export class VspAnchored { @Input() align: AnchoredAlign = 'start'; @Input() width?: number; @Input() layerClass = 'ui-menu'; /** Controlled open state. Leave unset for uncontrolled. */ @Input('open') openInput?: boolean; @Output() openChange = new EventEmitter(); @ViewChild('trig') trig!: ElementRef; @ViewChild('layer') layer?: ElementRef; internalOpen = false; rect: DOMRect | null = null; layerSize: { w: number; h: number } | null = null; get isOpen(): boolean { return this.openInput !== undefined ? this.openInput : this.internalOpen; } private setOpen(next: boolean): void { if (this.openInput === undefined) this.internalOpen = next; this.openChange.emit(next); } toggle(): void { const next = !this.isOpen; this.setOpen(next); if (next) { requestAnimationFrame(() => { this.place(); requestAnimationFrame(() => this.measure()); }); } else { this.layerSize = null; } } close(): void { this.setOpen(false); this.layerSize = null; } place(): void { if (this.trig) this.rect = this.trig.nativeElement.getBoundingClientRect(); } private measure(): void { if (this.layer) { // offsetWidth/Height = untransformed layout box (open animation safe). const el = this.layer.nativeElement; this.layerSize = { w: el.offsetWidth, h: el.offsetHeight }; } } get layerStyle(): string { const r = this.rect; if (!r) return ''; const MARGIN = 8; const vw = window.innerWidth; const vh = window.innerHeight; const lw = this.layerSize?.w ?? this.width ?? r.width; const lh = this.layerSize?.h ?? 0; let top = r.bottom + 6; if (lh && top + lh > vh - MARGIN && r.top - 6 - lh >= MARGIN) top = r.top - 6 - lh; let left = this.align === 'end' ? r.right - lw : r.left; left = Math.max(MARGIN, Math.min(left, vw - MARGIN - lw)); if (lh) top = Math.max(MARGIN, Math.min(top, vh - MARGIN - lh)); let s = `position:fixed;top:${top}px;left:${left}px;min-width:${this.width ?? r.width}px;max-height:calc(100vh - ${MARGIN * 2}px);overflow-y:auto;z-index:320;`; if (!this.layerSize) s += 'visibility:hidden;'; return s; } @HostListener('document:mousedown', ['$event']) onDoc(e: MouseEvent): void { if (!this.isOpen) return; const t = e.target as Node; if (!this.layer?.nativeElement.contains(t) && !this.trig?.nativeElement.contains(t)) this.close(); } @HostListener('document:keydown.escape') onEsc(): void { if (this.isOpen) this.close(); } @HostListener('window:resize') onResize(): void { if (this.isOpen) this.place(); } } export interface MenuItem { label?: string; kbd?: string; /** Optional leading icon name (see `vsp-icon`). */ icon?: string; danger?: boolean; heading?: boolean; sep?: boolean; disabled?: boolean; /** Render as a checkable item — shows a tick and keeps the menu open on select. */ type?: 'checkbox' | 'radio'; checked?: boolean; /** Nested items — renders a submenu that flies out to the side. */ items?: MenuItem[]; onClick?: () => void; } @Component({ selector: 'vsp-menu-items', imports: [VspIcon, forwardRef(() => VspSubMenuItem)], template: `@for (it of items; track $index) { @if (it.sep) {
} @else if (it.heading) {
{{ it.label }}
} @else if (it.items && it.items.length) { } @else { } }`, }) export class VspMenuItems { @Input() items: MenuItem[] = []; @Input() close: () => void = () => {}; nav = menuNav; role(it: MenuItem): string { return it.type === 'checkbox' ? 'menuitemcheckbox' : it.type === 'radio' ? 'menuitemradio' : 'menuitem'; } pick(it: MenuItem): void { it.onClick?.(); if (!it.type) this.close(); } } @Component({ selector: 'vsp-sub-menu-item', imports: [VspIcon, forwardRef(() => VspMenuItems)], template: ` @if (open && rect) { } `, }) export class VspSubMenuItem { @Input() item!: MenuItem; @Input() close: () => void = () => {}; @ViewChild('btn') btn?: ElementRef; open = false; rect: DOMRect | null = null; private timer: ReturnType | null = null; private cancel(): void { if (this.timer != null) { clearTimeout(this.timer); this.timer = null; } } openNow(): void { this.cancel(); if (this.btn) this.rect = this.btn.nativeElement.getBoundingClientRect(); this.open = true; } closeSoon(): void { this.cancel(); this.timer = setTimeout(() => (this.open = false), 130); } onKey(e: KeyboardEvent): void { if (e.key === 'ArrowRight') { e.preventDefault(); this.openNow(); } else if (e.key === 'ArrowLeft') { e.preventDefault(); this.open = false; } else menuNav(e); } } @Component({ selector: 'vsp-dropdown-menu', imports: [VspAnchored, VspMenuItems], template: ` `, }) export class VspDropdownMenu { @Input() items: MenuItem[] = []; @Input() align: AnchoredAlign = 'end'; @Input() width?: number; @Input() open?: boolean; @Output() openChange = new EventEmitter(); @ViewChild('a') anchored?: VspAnchored; closeFn = (): void => this.anchored?.close(); } @Component({ selector: 'vsp-popover', imports: [VspAnchored], template: ` `, }) export class VspPopover { @Input() align: AnchoredAlign = 'start'; @Input() width = 260; @Input() open?: boolean; @Output() openChange = new EventEmitter(); }