import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, HostListener, ElementRef, inject } from '@angular/core'; import { CommonModule } from '@angular/common'; export type FabMenuSize = 'sm' | 'md' | 'lg'; export type FabMenuPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left'; export type FabMenuVariant = 'primary' | 'secondary' | 'success' | 'danger'; export type FabMenuDirection = 'up' | 'down' | 'left' | 'right' | 'auto'; export interface FabMenuItem { id: string; label: string; icon?: string; disabled?: boolean; variant?: FabMenuVariant; } @Component({ selector: 'ui-fab-menu', standalone: true, imports: [CommonModule], changeDetection: ChangeDetectionStrategy.OnPush, encapsulation: ViewEncapsulation.None, template: `
@if (isOpen) { } @if (isOpen && backdrop) { }
`, styleUrl: './fab-menu.component.scss' }) export class FabMenuComponent { @Input() size: FabMenuSize = 'md'; @Input() variant: FabMenuVariant = 'primary'; @Input() position: FabMenuPosition = 'bottom-right'; @Input() direction: FabMenuDirection = 'auto'; @Input() menuItems: FabMenuItem[] = []; @Input() disabled = false; @Input() triggerIcon?: string; @Input() closeIcon?: string; @Input() triggerLabel = 'Open menu'; @Input() backdrop = true; @Input() closeOnItemClick = true; @Input() closeOnOutsideClick = true; @Output() opened = new EventEmitter(); @Output() closed = new EventEmitter(); @Output() itemClicked = new EventEmitter<{item: FabMenuItem, event: Event}>(); private elementRef = inject(ElementRef); isOpen = false; isAnimating = false; get actualDirection(): FabMenuDirection { if (this.direction !== 'auto') return this.direction; // Auto-determine direction based on position switch (this.position) { case 'bottom-right': case 'bottom-left': return 'up'; case 'top-right': case 'top-left': return 'down'; default: return 'up'; } } getFabMenuClasses(): string { return [ 'ui-fab-menu', `ui-fab-menu--${this.size}`, `ui-fab-menu--${this.variant}`, `ui-fab-menu--${this.position}`, `ui-fab-menu--${this.actualDirection}`, this.isOpen ? 'ui-fab-menu--open' : '', this.disabled ? 'ui-fab-menu--disabled' : '', this.isAnimating ? 'ui-fab-menu--animating' : '' ].filter(Boolean).join(' '); } getTriggerClasses(): string { return [ 'ui-fab-menu__trigger', `ui-fab-menu__trigger--${this.size}`, `ui-fab-menu__trigger--${this.variant}`, this.isOpen ? 'ui-fab-menu__trigger--active' : '' ].filter(Boolean).join(' '); } getItemClasses(item: FabMenuItem): string { const variant = item.variant || this.variant; return [ 'ui-fab-menu__item', `ui-fab-menu__item--${variant}`, item.disabled ? 'ui-fab-menu__item--disabled' : '' ].filter(Boolean).join(' '); } @HostListener('document:click', ['$event']) onDocumentClick(event: Event): void { const target = event.target as Node; if (this.closeOnOutsideClick && this.isOpen && target && !this.elementRef.nativeElement.contains(target)) { this.close(); } } @HostListener('keydown.escape') onEscapePress(): void { if (this.isOpen) { this.close(); } } toggle(event?: Event): void { if (this.disabled) return; if (this.isOpen) { this.close(); } else { this.open(); } } open(): void { if (this.disabled || this.isOpen) return; this.isAnimating = true; this.isOpen = true; this.opened.emit(); // Focus first menu item after animation setTimeout(() => { this.isAnimating = false; this.focusFirstMenuItem(); }, 200); } close(): void { if (!this.isOpen) return; this.isAnimating = true; this.isOpen = false; this.closed.emit(); setTimeout(() => { this.isAnimating = false; }, 200); } handleItemClick(item: FabMenuItem, event: Event): void { if (item.disabled || this.disabled) return; this.itemClicked.emit({ item, event }); if (this.closeOnItemClick) { this.close(); } } handleTriggerKeydown(event: KeyboardEvent): void { switch (event.key) { case 'Enter': case ' ': event.preventDefault(); this.toggle(); break; case 'ArrowUp': if (this.actualDirection === 'up') { event.preventDefault(); if (!this.isOpen) this.open(); } break; case 'ArrowDown': if (this.actualDirection === 'down') { event.preventDefault(); if (!this.isOpen) this.open(); } break; } } handleItemKeydown(item: FabMenuItem, event: KeyboardEvent): void { switch (event.key) { case 'Enter': case ' ': event.preventDefault(); this.handleItemClick(item, event); break; case 'ArrowUp': event.preventDefault(); this.focusPreviousItem(event.target as HTMLElement); break; case 'ArrowDown': event.preventDefault(); this.focusNextItem(event.target as HTMLElement); break; case 'Tab': if (event.shiftKey) { this.focusPreviousItem(event.target as HTMLElement); } else { this.focusNextItem(event.target as HTMLElement); } break; } } private focusFirstMenuItem(): void { const firstItem = this.elementRef.nativeElement.querySelector('.ui-fab-menu__item:not([disabled])'); if (firstItem) { firstItem.focus(); } } private focusNextItem(currentElement: HTMLElement): void { const items = Array.from(this.elementRef.nativeElement.querySelectorAll('.ui-fab-menu__item:not([disabled])')); const currentIndex = items.indexOf(currentElement); const nextIndex = (currentIndex + 1) % items.length; (items[nextIndex] as HTMLElement).focus(); } private focusPreviousItem(currentElement: HTMLElement): void { const items = Array.from(this.elementRef.nativeElement.querySelectorAll('.ui-fab-menu__item:not([disabled])')); const currentIndex = items.indexOf(currentElement); const previousIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1; (items[previousIndex] as HTMLElement).focus(); } }