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();
}
}