🎯 Implementation Complete! This library has been extracted from the monorepo and is ready for Git submodule distribution. Features: - Standardized SCSS imports (no relative paths) - Optimized public-api.ts exports - Independent Angular library structure - Ready for consumer integration as submodule 🚀 Generated with Claude Code (https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
285 lines
8.2 KiB
TypeScript
285 lines
8.2 KiB
TypeScript
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: `
|
|
<div
|
|
[class]="getFabMenuClasses()">
|
|
|
|
<!-- Menu Items -->
|
|
@if (isOpen) {
|
|
<div class="ui-fab-menu__items" role="menu">
|
|
@for (item of menuItems; track item.id) {
|
|
<button
|
|
type="button"
|
|
[class]="getItemClasses(item)"
|
|
[disabled]="item.disabled || disabled"
|
|
[attr.aria-label]="item.label"
|
|
role="menuitem"
|
|
[tabindex]="isOpen ? 0 : -1"
|
|
(click)="handleItemClick(item, $event)"
|
|
(keydown)="handleItemKeydown(item, $event)">
|
|
|
|
@if (item.icon) {
|
|
<span class="ui-fab-menu__item-icon" [innerHTML]="item.icon"></span>
|
|
}
|
|
|
|
<span class="ui-fab-menu__item-label">{{ item.label }}</span>
|
|
|
|
<div class="ui-fab-menu__item-ripple"></div>
|
|
</button>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<!-- Main FAB Button -->
|
|
<button
|
|
type="button"
|
|
[class]="getTriggerClasses()"
|
|
[disabled]="disabled"
|
|
[attr.aria-expanded]="isOpen"
|
|
[attr.aria-haspopup]="true"
|
|
[attr.aria-label]="triggerLabel"
|
|
role="button"
|
|
(click)="toggle($event)"
|
|
(keydown)="handleTriggerKeydown($event)">
|
|
|
|
@if (isOpen && closeIcon) {
|
|
<span class="ui-fab-menu__trigger-icon ui-fab-menu__trigger-icon--close" [innerHTML]="closeIcon"></span>
|
|
} @else if (triggerIcon) {
|
|
<span class="ui-fab-menu__trigger-icon ui-fab-menu__trigger-icon--open" [innerHTML]="triggerIcon"></span>
|
|
}
|
|
|
|
<ng-content></ng-content>
|
|
|
|
<div class="ui-fab-menu__trigger-ripple"></div>
|
|
</button>
|
|
|
|
<!-- Backdrop -->
|
|
@if (isOpen && backdrop) {
|
|
<div
|
|
class="ui-fab-menu__backdrop"
|
|
(click)="close()"
|
|
role="presentation"></div>
|
|
}
|
|
</div>
|
|
`,
|
|
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<void>();
|
|
@Output() closed = new EventEmitter<void>();
|
|
@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();
|
|
}
|
|
} |