This repository has been archived on 2026-06-18. You can view files and clone it, but cannot push or open issues or pull requests.
Files
ui-essentials/src/lib/components/buttons/fab-menu/fab-menu.component.ts
Jules 0a0cade343 Initial commit - Angular library: ui-essentials
🎯 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>
2025-09-11 21:12:46 +10:00

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