diff --git a/projects/demo-ui-essentials/src/app/demos/bottom-navigation-demo/bottom-navigation-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/bottom-navigation-demo/bottom-navigation-demo.component.scss new file mode 100644 index 0000000..8cf6955 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/bottom-navigation-demo/bottom-navigation-demo.component.scss @@ -0,0 +1,138 @@ +@use '../../../../../shared-ui/src/styles/semantic/index' as *; + +.demo-container { + padding-bottom: 120px; // Extra space to accommodate bottom navigation examples +} + +.demo-section { + margin-bottom: $semantic-spacing-layout-section-lg; +} + +.demo-row { + display: flex; + gap: $semantic-spacing-component-lg; + flex-wrap: wrap; + + &--vertical { + flex-direction: column; + gap: $semantic-spacing-component-md; + } +} + +.demo-item { + flex: 1; + min-width: 300px; + + h4 { + margin-bottom: $semantic-spacing-component-sm; + color: $semantic-color-text-primary; + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + } +} + +.demo-bottom-nav-container { + position: relative; + height: 80px; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-radius: $semantic-border-radius-md; + overflow: hidden; + margin-bottom: $semantic-spacing-component-md; + + // Override the fixed positioning for demo purposes + :deep(.ui-bottom-navigation) { + position: absolute !important; + bottom: 0; + left: 0; + right: 0; + } +} + +.demo-info { + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-sm; + padding: $semantic-spacing-component-md; + margin: $semantic-spacing-component-md 0; + + p { + margin: 0 0 $semantic-spacing-component-xs 0; + color: $semantic-color-text-primary; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + + &:last-child { + margin-bottom: 0; + } + + strong { + font-weight: $semantic-typography-font-weight-semibold; + color: $semantic-color-text-primary; + } + } +} + +.demo-controls { + display: flex; + gap: $semantic-spacing-component-sm; + flex-wrap: wrap; +} + +.demo-button { + padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x; + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border: none; + border-radius: $semantic-border-button-radius; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Typography + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + line-height: map-get($semantic-typography-button-medium, line-height); + + &:hover { + box-shadow: $semantic-shadow-button-hover; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + box-shadow: $semantic-shadow-button-rest; + transform: translateY(1px); + } + + &.active { + background: $semantic-color-secondary; + color: $semantic-color-on-secondary; + } +} + +// Responsive adjustments +@media (max-width: $semantic-breakpoint-sm - 1) { + .demo-row { + flex-direction: column; + } + + .demo-item { + min-width: auto; + } + + .demo-controls { + flex-direction: column; + } + + .demo-button { + width: 100%; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/bottom-navigation-demo/bottom-navigation-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/bottom-navigation-demo/bottom-navigation-demo.component.ts new file mode 100644 index 0000000..8a7c81c --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/bottom-navigation-demo/bottom-navigation-demo.component.ts @@ -0,0 +1,268 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faHome, faSearch, faUser, faHeart, faCog, faShoppingCart, faBell, faBookmark } from '@fortawesome/free-solid-svg-icons'; +import { BottomNavigationComponent, BottomNavigationItem } from '../../../../../ui-essentials/src/lib/components/navigation/bottom-navigation'; + +@Component({ + selector: 'ui-bottom-navigation-demo', + standalone: true, + imports: [CommonModule, BottomNavigationComponent, FontAwesomeModule], + template: ` +
+

Bottom Navigation Demo

+ + +
+

Sizes

+
+ @for (size of sizes; track size) { +
+

{{ size.toUpperCase() }} Size

+
+ + + + + + + +
+
+ } +
+
+ + +
+

Variants

+
+ @for (variant of variants; track variant) { +
+

{{ variant.charAt(0).toUpperCase() + variant.slice(1) }} Variant

+
+ + + + + + + +
+
+ } +
+
+ + +
+

With Badges

+
+ + + + + + + + +
+
+ + +
+

States

+
+
+

Elevated

+
+ + + + + + + +
+
+ +
+

With Disabled Item

+
+ + + + + + + +
+
+
+
+ + +
+

Interactive Example

+
+ + + + + + + +
+ +
+

Active Item: {{ getActiveItemLabel() }}

+

Click Count: {{ clickCount }}

+

Last Clicked: {{ lastClickedItem || 'None' }}

+
+ +
+ + +
+
+
+ `, + styleUrl: './bottom-navigation-demo.component.scss' +}) +export class BottomNavigationDemoComponent { + // FontAwesome icons + faHome = faHome; + faSearch = faSearch; + faUser = faUser; + faHeart = faHeart; + faCog = faCog; + faShoppingCart = faShoppingCart; + faBell = faBell; + faBookmark = faBookmark; + + // Demo data + sizes = ['sm', 'md', 'lg'] as const; + variants = ['default', 'primary', 'secondary'] as const; + + // Active item tracking for different variants + activeItemIds: Record = { + sm: 'home', + md: 'home', + lg: 'home', + default: 'home', + primary: 'home', + secondary: 'home' + }; + + badgeActiveItemId = 'home'; + elevatedActiveItemId = 'home'; + disabledActiveItemId = 'home'; + interactiveActiveItemId = 'home'; + + // Interactive demo state + clickCount = 0; + lastClickedItem = ''; + isHidden = false; + heartBadgeCount = 3; + + // Item configurations + basicItems: BottomNavigationItem[] = [ + { id: 'home', label: 'Home', icon: 'home' }, + { id: 'search', label: 'Search', icon: 'search' }, + { id: 'profile', label: 'Profile', icon: 'profile' }, + { id: 'settings', label: 'Settings', icon: 'settings' } + ]; + + itemsWithBadges: BottomNavigationItem[] = [ + { id: 'home', label: 'Home', icon: 'home' }, + { id: 'cart', label: 'Cart', icon: 'cart', badge: 2 }, + { id: 'notifications', label: 'Alerts', icon: 'notifications', badge: '99+' }, + { id: 'saved', label: 'Saved', icon: 'saved', badge: 15 }, + { id: 'profile', label: 'Profile', icon: 'profile' } + ]; + + itemsWithDisabled: BottomNavigationItem[] = [ + { id: 'home', label: 'Home', icon: 'home' }, + { id: 'search', label: 'Search', icon: 'search', disabled: true }, + { id: 'profile', label: 'Profile', icon: 'profile' }, + { id: 'settings', label: 'Settings', icon: 'settings' } + ]; + + get interactiveItems(): BottomNavigationItem[] { + return [ + { id: 'home', label: 'Home', icon: 'home' }, + { id: 'search', label: 'Search', icon: 'search' }, + { id: 'favorites', label: 'Favorites', icon: 'favorites', badge: this.heartBadgeCount > 0 ? this.heartBadgeCount : undefined }, + { id: 'profile', label: 'Profile', icon: 'profile' } + ]; + } + + handleActiveItemChange(context: string, itemId: string): void { + this.activeItemIds[context] = itemId; + } + + handleItemClick(item: BottomNavigationItem): void { + console.log('Item clicked:', item); + } + + handleInteractiveItemChange(itemId: string): void { + this.interactiveActiveItemId = itemId; + } + + handleInteractiveItemClick(item: BottomNavigationItem): void { + this.clickCount++; + this.lastClickedItem = item.label; + console.log('Interactive item clicked:', item); + } + + getActiveItemLabel(): string { + const activeItem = this.interactiveItems.find(item => item.id === this.interactiveActiveItemId); + return activeItem?.label || 'None'; + } + + toggleHidden(): void { + this.isHidden = !this.isHidden; + // In a real implementation, you would update the hidden property of the navigation + // For demo purposes, this just toggles a state variable + } + + incrementBadge(): void { + this.heartBadgeCount++; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/command-palette-demo/command-palette-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/command-palette-demo/command-palette-demo.component.scss new file mode 100644 index 0000000..7af20fb --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/command-palette-demo/command-palette-demo.component.scss @@ -0,0 +1,380 @@ +@use '../../../../../shared-ui/src/styles/semantic' as *; + +.demo-container { + padding: $semantic-spacing-layout-section-lg; + max-width: 1200px; + margin: 0 auto; +} + +.demo-header { + text-align: center; + margin-bottom: $semantic-spacing-layout-section-xl; +} + +.demo-title { + display: flex; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-sm; + margin: 0 0 $semantic-spacing-component-md 0; + font-family: map-get($semantic-typography-heading-h2, font-family); + font-size: map-get($semantic-typography-heading-h2, font-size); + font-weight: map-get($semantic-typography-heading-h2, font-weight); + line-height: map-get($semantic-typography-heading-h2, line-height); + color: $semantic-color-text-primary; +} + +.demo-title-icon { + color: $semantic-color-primary; +} + +.demo-description { + margin: 0; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + color: $semantic-color-text-secondary; + + kbd { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px $semantic-spacing-component-xs; + margin: 0 2px; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-sm; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-primary; + box-shadow: $semantic-shadow-elevation-1; + } +} + +.demo-section { + margin-bottom: $semantic-spacing-layout-section-xl; +} + +.demo-section-title { + margin: 0 0 $semantic-spacing-component-lg 0; + font-family: map-get($semantic-typography-heading-h3, font-family); + font-size: map-get($semantic-typography-heading-h3, font-size); + font-weight: map-get($semantic-typography-heading-h3, font-weight); + line-height: map-get($semantic-typography-heading-h3, line-height); + color: $semantic-color-text-primary; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + padding-bottom: $semantic-spacing-component-sm; +} + +.demo-actions { + display: flex; + gap: $semantic-spacing-component-md; + flex-wrap: wrap; + align-items: center; +} + +.demo-button { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x; + border: $semantic-border-width-1 solid transparent; + border-radius: $semantic-border-button-radius; + background: transparent; + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + line-height: map-get($semantic-typography-button-medium, line-height); + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + text-decoration: none; + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-color: $semantic-color-primary; + + &:hover { + background: darken($semantic-color-primary, 10%); + border-color: darken($semantic-color-primary, 10%); + box-shadow: $semantic-shadow-button-hover; + } + } + + &--secondary { + background: $semantic-color-secondary; + color: $semantic-color-on-secondary; + border-color: $semantic-color-secondary; + + &:hover { + background: darken($semantic-color-secondary, 10%); + border-color: darken($semantic-color-secondary, 10%); + box-shadow: $semantic-shadow-button-hover; + } + } + + &--outline { + color: $semantic-color-text-primary; + border-color: $semantic-color-border-primary; + + &:hover { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-border-secondary; + box-shadow: $semantic-shadow-button-hover; + } + } +} + +.demo-config-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: $semantic-spacing-component-lg; + margin-bottom: $semantic-spacing-component-lg; +} + +.demo-config-item { + display: flex; + flex-direction: column; + gap: $semantic-spacing-component-xs; +} + +.demo-label { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-label, font-family); + font-size: map-get($semantic-typography-label, font-size); + font-weight: map-get($semantic-typography-label, font-weight); + line-height: map-get($semantic-typography-label, line-height); + color: $semantic-color-text-primary; + cursor: pointer; +} + +.demo-checkbox { + width: 16px; + height: 16px; + accent-color: $semantic-color-primary; + cursor: pointer; +} + +.demo-input, +.demo-select { + padding: $semantic-spacing-interactive-input-padding-y $semantic-spacing-interactive-input-padding-x; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-input-radius; + background: $semantic-color-surface-primary; + font-family: map-get($semantic-typography-input, font-family); + font-size: map-get($semantic-typography-input, font-size); + font-weight: map-get($semantic-typography-input, font-weight); + line-height: map-get($semantic-typography-input, line-height); + color: $semantic-color-text-primary; + transition: border-color $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:focus { + outline: none; + border-color: $semantic-color-focus; + box-shadow: 0 0 0 2px rgba($semantic-color-focus, 0.2); + } +} + +.demo-features { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: $semantic-spacing-component-lg; +} + +.demo-feature { + padding: $semantic-spacing-component-lg; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-lg; + background: $semantic-color-surface-primary; + text-align: center; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + border-color: $semantic-color-primary; + box-shadow: $semantic-shadow-elevation-2; + transform: translateY(-2px); + } + + h4 { + margin: $semantic-spacing-component-sm 0 $semantic-spacing-component-xs 0; + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + color: $semantic-color-text-primary; + } + + p { + margin: 0; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-secondary; + } +} + +.demo-feature-icon { + font-size: 2rem; + color: $semantic-color-primary; +} + +.demo-stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: $semantic-spacing-component-lg; +} + +.demo-stat { + padding: $semantic-spacing-component-lg; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-lg; + background: $semantic-color-surface-primary; + text-align: center; +} + +.demo-stat-value { + font-family: map-get($semantic-typography-heading-h2, font-family); + font-size: map-get($semantic-typography-heading-h2, font-size); + font-weight: map-get($semantic-typography-heading-h2, font-weight); + line-height: map-get($semantic-typography-heading-h2, line-height); + color: $semantic-color-primary; + margin-bottom: $semantic-spacing-component-xs; +} + +.demo-stat-label { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.demo-shortcuts { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: $semantic-spacing-component-md; +} + +.demo-shortcut { + display: flex; + align-items: center; + gap: $semantic-spacing-component-md; + padding: $semantic-spacing-component-md; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-md; + background: $semantic-color-surface-primary; + + span { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + } +} + +.demo-kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + height: 28px; + padding: 0 $semantic-spacing-component-xs; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-sm; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-primary; + box-shadow: $semantic-shadow-elevation-1; + white-space: nowrap; +} + +// Toast animation styles +@keyframes slideIn { + from { + transform: translateX(100%); + opacity: 0; + } + to { + transform: translateX(0); + opacity: 1; + } +} + +@keyframes slideOut { + from { + transform: translateX(0); + opacity: 1; + } + to { + transform: translateX(100%); + opacity: 0; + } +} + +// Responsive design +@media (max-width: calc(#{$semantic-breakpoint-md} - 1px)) { + .demo-container { + padding: $semantic-spacing-layout-section-md; + } + + .demo-actions { + justify-content: center; + } + + .demo-config-grid { + grid-template-columns: 1fr; + } + + .demo-features { + grid-template-columns: 1fr; + } + + .demo-stats { + grid-template-columns: repeat(3, 1fr); + } + + .demo-shortcuts { + grid-template-columns: 1fr; + } +} + +@media (max-width: calc(#{$semantic-breakpoint-sm} - 1px)) { + .demo-container { + padding: $semantic-spacing-component-lg; + } + + .demo-title { + flex-direction: column; + gap: $semantic-spacing-component-xs; + + .demo-title-icon { + font-size: 2rem; + } + } + + .demo-stats { + grid-template-columns: 1fr; + } + + .demo-actions { + flex-direction: column; + align-items: stretch; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/command-palette-demo/command-palette-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/command-palette-demo/command-palette-demo.component.ts new file mode 100644 index 0000000..bc58aee --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/command-palette-demo/command-palette-demo.component.ts @@ -0,0 +1,574 @@ +import { Component, OnInit, OnDestroy, ViewChild, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { Router } from '@angular/router'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { + faHome, + faCog, + faUser, + faSearch, + faFile, + faEdit, + faEye, + faTools, + faQuestion, + faPlus, + faSave, + faCopy, + faPaste, + faUndo, + faRedo, + faKeyboard, + faPalette, + faRocket +} from '@fortawesome/free-solid-svg-icons'; +import { Command, CommandCategory, CommandExecutionContext, CommandPaletteComponent, CommandPaletteService, createShortcuts, GlobalKeyboardDirective } from '../../../../../ui-essentials/src/lib/components/overlays/command-palette'; + + +@Component({ + selector: 'ui-command-palette-demo', + standalone: true, + imports: [ + CommonModule, + FontAwesomeModule, + CommandPaletteComponent, + GlobalKeyboardDirective + ], + template: ` +
+
+

+ + Command Palette Demo +

+

+ A powerful command palette for quick navigation and actions. Press Ctrl+K (or ⌘K on Mac) to open. +

+
+ + +
+

Quick Start

+
+ + + + + +
+
+ + +
+

Configuration

+
+
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+ +
+
+
+ + +
+

Features

+
+
+ +

Keyboard Navigation

+

Full keyboard support with arrow keys, Enter, and Escape

+
+ +
+ +

Fuzzy Search

+

Intelligent search with typo tolerance and keyword matching

+
+ +
+ +

Quick Actions

+

Execute commands instantly with global keyboard shortcuts

+
+ +
+ +

Categorization

+

Organize commands by category for better discoverability

+
+ +
+ +

Recent Commands

+

Track and show recently used commands for quick access

+
+ +
+ +

Context Aware

+

Commands can be shown or hidden based on application state

+
+
+
+ + +
+

Usage Statistics

+
+
+
{{ registeredCommandsCount }}
+
Registered Commands
+
+
+
{{ recentCommandsCount }}
+
Recent Commands
+
+
+
{{ executionCount }}
+
Commands Executed
+
+
+
+ + +
+

Keyboard Shortcuts

+
+
+ Ctrl + K + Open Command Palette +
+
+ / + Navigate Results +
+
+ Enter + Execute Selected Command +
+
+ Esc + Close Palette +
+
+ Tab + Navigate Results (Alternative) +
+
+ / + Quick Search +
+
+
+ + + +
+ `, + styleUrl: './command-palette-demo.component.scss' +}) +export class CommandPaletteDemoComponent implements OnInit, OnDestroy { + @ViewChild('commandPalette') commandPalette!: CommandPaletteComponent; + + private router = inject(Router); + private commandService = inject(CommandPaletteService); + + // Icons + readonly faPalette = faPalette; + readonly faKeyboard = faKeyboard; + readonly faPlus = faPlus; + readonly faSearch = faSearch; + readonly faRocket = faRocket; + readonly faCog = faCog; + readonly faCopy = faCopy; + readonly faEye = faEye; + readonly faHome = faHome; + readonly faUser = faUser; + readonly faFile = faFile; + readonly faEdit = faEdit; + readonly faTools = faTools; + readonly faQuestion = faQuestion; + readonly faSave = faSave; + readonly faUndo = faUndo; + readonly faRedo = faRedo; + readonly faPaste = faPaste; + + // Component state + paletteSize: 'md' | 'lg' | 'xl' = 'lg'; + executionCount = 0; + + config = { + showCategories: true, + showShortcuts: true, + showRecent: true, + maxResults: 20, + recentLimit: 5 + }; + + globalShortcuts = [ + createShortcuts().commandPalette(), + createShortcuts().quickSearch(), + createShortcuts().create('/', { preventDefault: true }) + ]; + + get registeredCommandsCount(): number { + return this.commandService.commands().length; + } + + get recentCommandsCount(): number { + return this.commandService.recentCommands().length; + } + + ngOnInit(): void { + this.registerSampleCommands(); + this.applyConfig(); + } + + ngOnDestroy(): void { + // Clean up registered commands + this.commandService.clearCommands(); + } + + openCommandPalette(): void { + this.commandPalette.open(); + } + + handleGlobalShortcut(event: any): void { + const shortcut = event.shortcut; + + if (shortcut.key === 'k' && (shortcut.ctrlKey || shortcut.metaKey)) { + this.commandPalette.toggle(); + } else if (shortcut.key === '/') { + this.commandPalette.open(); + } + } + + registerSampleCommands(): void { + const sampleCommands: Command[] = [ + // Navigation Commands + { + id: 'nav-home', + title: 'Go Home', + description: 'Navigate to the home page', + icon: faHome, + category: CommandCategory.NAVIGATION, + keywords: ['home', 'dashboard', 'main'], + shortcut: ['Ctrl', 'H'], + handler: () => this.showToast('Navigating to home...'), + order: 1 + }, + { + id: 'nav-profile', + title: 'View Profile', + description: 'Go to user profile page', + icon: faUser, + category: CommandCategory.NAVIGATION, + keywords: ['profile', 'user', 'account'], + handler: () => this.showToast('Opening profile...'), + order: 2 + }, + { + id: 'nav-settings', + title: 'Open Settings', + description: 'Access application settings', + icon: faCog, + category: CommandCategory.SETTINGS, + keywords: ['settings', 'preferences', 'config'], + shortcut: ['Ctrl', ','], + handler: () => this.showToast('Opening settings...'), + order: 1 + }, + + // File Commands + { + id: 'file-new', + title: 'New File', + description: 'Create a new file', + icon: faFile, + category: CommandCategory.FILE, + keywords: ['new', 'create', 'file', 'document'], + shortcut: ['Ctrl', 'N'], + handler: () => this.showToast('Creating new file...'), + order: 1 + }, + { + id: 'file-save', + title: 'Save File', + description: 'Save the current file', + icon: faSave, + category: CommandCategory.FILE, + keywords: ['save', 'file', 'document'], + shortcut: ['Ctrl', 'S'], + handler: () => this.showToast('Saving file...'), + order: 2 + }, + + // Edit Commands + { + id: 'edit-copy', + title: 'Copy', + description: 'Copy selected content', + icon: faCopy, + category: CommandCategory.EDIT, + keywords: ['copy', 'clipboard'], + shortcut: ['Ctrl', 'C'], + handler: () => this.showToast('Content copied!'), + order: 1 + }, + { + id: 'edit-paste', + title: 'Paste', + description: 'Paste from clipboard', + icon: faPaste, + category: CommandCategory.EDIT, + keywords: ['paste', 'clipboard'], + shortcut: ['Ctrl', 'V'], + handler: () => this.showToast('Content pasted!'), + order: 2 + }, + { + id: 'edit-undo', + title: 'Undo', + description: 'Undo last action', + icon: faUndo, + category: CommandCategory.EDIT, + keywords: ['undo', 'revert'], + shortcut: ['Ctrl', 'Z'], + handler: () => this.showToast('Action undone!'), + order: 3 + }, + { + id: 'edit-redo', + title: 'Redo', + description: 'Redo last undone action', + icon: faRedo, + category: CommandCategory.EDIT, + keywords: ['redo', 'repeat'], + shortcut: ['Ctrl', 'Y'], + handler: () => this.showToast('Action redone!'), + order: 4 + }, + + // Search Commands + { + id: 'search-global', + title: 'Global Search', + description: 'Search across all content', + icon: faSearch, + category: CommandCategory.SEARCH, + keywords: ['search', 'find', 'global'], + shortcut: ['Ctrl', 'Shift', 'F'], + handler: () => this.showToast('Opening global search...'), + order: 1 + }, + + // Tools Commands + { + id: 'tools-theme', + title: 'Toggle Theme', + description: 'Switch between light and dark theme', + icon: faTools, + category: CommandCategory.TOOLS, + keywords: ['theme', 'dark', 'light', 'toggle'], + handler: () => this.showToast('Theme toggled!'), + order: 1 + }, + + // Help Commands + { + id: 'help-docs', + title: 'Documentation', + description: 'Open help documentation', + icon: faQuestion, + category: CommandCategory.HELP, + keywords: ['help', 'docs', 'documentation'], + handler: () => this.showToast('Opening documentation...'), + order: 1 + }, + + // Action Commands + { + id: 'action-refresh', + title: 'Refresh Page', + description: 'Reload the current page', + icon: faRocket, + category: CommandCategory.ACTIONS, + keywords: ['refresh', 'reload', 'update'], + shortcut: ['F5'], + handler: () => this.showToast('Page refreshed!'), + order: 1 + } + ]; + + this.commandService.registerCommands(sampleCommands); + } + + clearCommands(): void { + this.commandService.clearCommands(); + this.commandService.clearRecentCommands(); + } + + updateConfig(key: string, event: Event): void { + const target = event.target as HTMLInputElement; + let value: any = target.type === 'checkbox' ? target.checked : + target.type === 'number' ? parseInt(target.value, 10) : + target.value; + + (this.config as any)[key] = value; + this.applyConfig(); + } + + updatePaletteSize(event: Event): void { + const target = event.target as HTMLSelectElement; + this.paletteSize = target.value as 'md' | 'lg' | 'xl'; + } + + private applyConfig(): void { + this.commandService.updateConfig({ + showCategories: this.config.showCategories, + showShortcuts: this.config.showShortcuts, + showRecent: this.config.showRecent, + maxResults: this.config.maxResults, + recentLimit: this.config.recentLimit + }); + } + + onPaletteOpened(): void { + console.log('Command palette opened'); + } + + onPaletteClosed(): void { + console.log('Command palette closed'); + } + + onCommandExecuted(event: { commandId: string; context: CommandExecutionContext }): void { + this.executionCount++; + console.log('Command executed:', event); + } + + private showToast(message: string): void { + // Simple toast implementation for demo + const toast = document.createElement('div'); + toast.className = 'demo-toast'; + toast.textContent = message; + toast.style.cssText = ` + position: fixed; + top: 20px; + right: 20px; + background: #333; + color: white; + padding: 12px 24px; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0,0,0,0.1); + z-index: 10000; + font-size: 14px; + animation: slideIn 0.3s ease; + `; + + document.body.appendChild(toast); + + setTimeout(() => { + toast.style.animation = 'slideOut 0.3s ease forwards'; + setTimeout(() => document.body.removeChild(toast), 300); + }, 3000); + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/container-demo/container-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/container-demo/container-demo.component.scss index 540d6fb..cbd5b49 100644 --- a/projects/demo-ui-essentials/src/app/demos/container-demo/container-demo.component.scss +++ b/projects/demo-ui-essentials/src/app/demos/container-demo/container-demo.component.scss @@ -78,12 +78,10 @@ margin: 0 auto; // Center the container position: relative; // For proper positioning context - // Override container component's auto margins that might be causing issues + // Ensure proper centering &.ui-container { - margin-left: auto !important; - margin-right: auto !important; - left: auto !important; - right: auto !important; + margin-left: auto; + margin-right: auto; } &.demo-container-tall { @@ -187,13 +185,9 @@ // Additional container demo specific styles .demo-containers-showcase { - // Force all containers to behave properly + // Ensure containers behave properly .ui-container { - // Ensure containers don't push beyond boundaries position: relative; - left: 0 !important; - right: 0 !important; - transform: none !important; } // Size-specific constraints to prevent overflow diff --git a/projects/demo-ui-essentials/src/app/demos/demos.routes.ts b/projects/demo-ui-essentials/src/app/demos/demos.routes.ts index 4e910ae..36433a9 100644 --- a/projects/demo-ui-essentials/src/app/demos/demos.routes.ts +++ b/projects/demo-ui-essentials/src/app/demos/demos.routes.ts @@ -59,6 +59,8 @@ import { SplitButtonDemoComponent } from './split-button-demo/split-button-demo. import { CommandPaletteDemoComponent } from './command-palette-demo/command-palette-demo.component'; import { TransferListDemoComponent } from './transfer-list-demo/transfer-list-demo.component'; import { FloatingToolbarDemoComponent } from './floating-toolbar-demo/floating-toolbar-demo.component'; +import { TagInputDemoComponent } from './tag-input-demo/tag-input-demo.component'; +import { IconButtonDemoComponent } from './icon-button-demo/icon-button-demo.component'; @Component({ @@ -87,6 +89,10 @@ import { FloatingToolbarDemoComponent } from './floating-toolbar-demo/floating-t } + @case ("icon-button") { + + } + @case ("cards") { } @@ -293,10 +299,14 @@ import { FloatingToolbarDemoComponent } from './floating-toolbar-demo/floating-t } + @case ("tag-input") { + + } + } `, - imports: [AvatarDemoComponent, ButtonDemoComponent, CardDemoComponent, + imports: [AvatarDemoComponent, ButtonDemoComponent, IconButtonDemoComponent, CardDemoComponent, ChipDemoComponent, TableDemoComponent, BadgeDemoComponent, MenuDemoComponent, InputDemoComponent, LayoutDemoComponent, RadioDemoComponent, CheckboxDemoComponent, @@ -308,7 +318,7 @@ import { FloatingToolbarDemoComponent } from './floating-toolbar-demo/floating-t SkeletonLoaderDemoComponent, EmptyStateDemoComponent, FileUploadDemoComponent, FormFieldDemoComponent, AutocompleteDemoComponent, BackdropDemoComponent, OverlayContainerDemoComponent, LoadingSpinnerDemoComponent, ProgressCircleDemoComponent, RangeSliderDemoComponent, ColorPickerDemoComponent, DividerDemoComponent, TooltipDemoComponent, AccordionDemoComponent, - PopoverDemoComponent, AlertDemoComponent, SnackbarDemoComponent, ToastDemoComponent, TreeViewDemoComponent, TimelineDemoComponent, StepperDemoComponent, FabMenuDemoComponent, EnhancedTableDemoComponent, SplitButtonDemoComponent, CommandPaletteDemoComponent, FloatingToolbarDemoComponent, TransferListDemoComponent] + PopoverDemoComponent, AlertDemoComponent, SnackbarDemoComponent, ToastDemoComponent, TreeViewDemoComponent, TimelineDemoComponent, StepperDemoComponent, FabMenuDemoComponent, EnhancedTableDemoComponent, SplitButtonDemoComponent, CommandPaletteDemoComponent, FloatingToolbarDemoComponent, TransferListDemoComponent, TagInputDemoComponent] }) diff --git a/projects/demo-ui-essentials/src/app/demos/enhanced-table-demo/enhanced-table-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/enhanced-table-demo/enhanced-table-demo.component.scss new file mode 100644 index 0000000..79349de --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/enhanced-table-demo/enhanced-table-demo.component.scss @@ -0,0 +1,387 @@ +@use '../../../../../shared-ui/src/styles/semantic' as *; + +.enhanced-table-demo { + padding: $semantic-spacing-layout-section-lg; + max-width: 1400px; + margin: 0 auto; + + h2 { + font-family: map-get($semantic-typography-heading-h2, font-family); + font-size: map-get($semantic-typography-heading-h2, font-size); + font-weight: map-get($semantic-typography-heading-h2, font-weight); + line-height: map-get($semantic-typography-heading-h2, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-content-heading; + } + + h3 { + font-family: map-get($semantic-typography-heading-h3, font-family); + font-size: map-get($semantic-typography-heading-h3, font-size); + font-weight: map-get($semantic-typography-heading-h3, font-weight); + line-height: map-get($semantic-typography-heading-h3, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-content-heading; + } + + h4 { + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-component-sm; + } + + p { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-secondary; + margin-bottom: $semantic-spacing-content-paragraph; + } +} + +// Demo Controls +.demo-controls { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: $semantic-spacing-grid-gap-lg; + margin-bottom: $semantic-spacing-layout-section-lg; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-card-radius; + box-shadow: $semantic-shadow-elevation-1; +} + +.demo-control-group { + h3 { + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: $semantic-typography-font-weight-semibold; + margin-bottom: $semantic-spacing-component-sm; + color: $semantic-color-text-primary; + } + + label { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + margin-bottom: $semantic-spacing-component-sm; + font-size: map-get($semantic-typography-body-medium, font-size); + font-family: map-get($semantic-typography-body-medium, font-family); + color: $semantic-color-text-primary; + cursor: pointer; + + input[type="checkbox"] { + accent-color: $semantic-color-primary; + } + + select { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-input-radius; + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + font-size: map-get($semantic-typography-body-medium, font-size); + + &:focus { + outline: none; + border-color: $semantic-color-focus; + box-shadow: 0 0 0 2px rgba($semantic-color-focus, 0.2); + } + } + } + + p { + font-size: map-get($semantic-typography-body-small, font-size); + color: $semantic-color-text-tertiary; + margin: 0; + } +} + +.demo-button { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + border: $semantic-border-width-1 solid $semantic-color-primary; + border-radius: $semantic-border-button-radius; + background: $semantic-color-primary; + color: $semantic-color-on-primary; + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + margin: $semantic-spacing-component-xs; + + &:hover:not(:disabled) { + box-shadow: $semantic-shadow-button-hover; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--secondary { + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + border-color: $semantic-color-border-primary; + + &:hover:not(:disabled) { + background: $semantic-color-surface-secondary; + } + } + + &--small { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + font-size: map-get($semantic-typography-button-small, font-size); + } +} + +// Performance Metrics +.performance-metrics { + margin-bottom: $semantic-spacing-layout-section-md; + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-input-radius; + border-left: 4px solid $semantic-color-info; +} + +.metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: $semantic-spacing-component-md; +} + +.metric { + display: flex; + flex-direction: column; + gap: $semantic-spacing-component-xs; + + .metric-label { + font-size: map-get($semantic-typography-body-small, font-size); + color: $semantic-color-text-secondary; + } + + .metric-value { + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: $semantic-typography-font-weight-semibold; + color: $semantic-color-primary; + } +} + +// Table Container +.table-container { + margin-bottom: $semantic-spacing-layout-section-lg; + border-radius: $semantic-border-card-radius; + overflow: hidden; + box-shadow: $semantic-shadow-elevation-2; +} + +// Cell Templates +.salary-cell { + font-weight: $semantic-typography-font-weight-semibold; + color: $semantic-color-success; +} + +.performance-cell { + display: flex; + align-items: center; + gap: $semantic-spacing-component-sm; + width: 100%; +} + +.performance-bar { + flex: 1; + height: 8px; + background: $semantic-color-border-subtle; + border-radius: $semantic-border-radius-full; + overflow: hidden; +} + +.performance-fill { + height: 100%; + border-radius: $semantic-border-radius-full; + transition: width $semantic-motion-duration-normal $semantic-motion-easing-ease; + + &.performance-excellent { + background: $semantic-color-success; + } + + &.performance-good { + background: $semantic-color-info; + } + + &.performance-average { + background: $semantic-color-warning; + } + + &.performance-poor { + background: $semantic-color-danger; + } +} + +.performance-text { + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: $semantic-typography-font-weight-medium; + min-width: 35px; + text-align: right; +} + +.action-buttons { + display: flex; + gap: $semantic-spacing-component-xs; +} + +.action-btn { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + border: none; + border-radius: $semantic-border-radius-sm; + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: $semantic-typography-font-weight-medium; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + + &:hover { + opacity: $semantic-opacity-hover; + } + } + + &--danger { + background: $semantic-color-danger; + color: $semantic-color-on-danger; + + &:hover { + opacity: $semantic-opacity-hover; + } + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 1px; + } +} + +// State Display +.state-display { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + gap: $semantic-spacing-grid-gap-md; + margin-bottom: $semantic-spacing-layout-section-lg; +} + +.state-section { + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-input-radius; + + h4 { + margin-bottom: $semantic-spacing-component-sm; + color: $semantic-color-text-primary; + } +} + +.state-content { + p { + margin: 0; + color: $semantic-color-text-tertiary; + font-style: italic; + } +} + +.sort-item, +.filter-item { + padding: $semantic-spacing-component-xs 0; + font-size: map-get($semantic-typography-body-small, font-size); + color: $semantic-color-text-primary; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + + &:last-child { + border-bottom: none; + } + + strong { + color: $semantic-color-primary; + } +} + +// Feature Demo +.feature-demo { + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-card-radius; + border-left: 4px solid $semantic-color-primary; +} + +.feature-list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); + gap: $semantic-spacing-grid-gap-lg; +} + +.feature-item { + h4 { + font-size: map-get($semantic-typography-body-large, font-size); + margin-bottom: $semantic-spacing-component-xs; + color: $semantic-color-text-primary; + } + + p { + font-size: map-get($semantic-typography-body-small, font-size); + color: $semantic-color-text-secondary; + margin: 0; + } +} + +// Responsive Design +@media (max-width: 1200px) { + .enhanced-table-demo { + padding: $semantic-spacing-component-md; + } + + .demo-controls { + grid-template-columns: 1fr; + } + + .feature-list { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + .state-display { + grid-template-columns: 1fr; + } + + .metrics-grid { + grid-template-columns: repeat(2, 1fr); + } + + .performance-cell { + flex-direction: column; + gap: $semantic-spacing-component-xs; + + .performance-bar { + width: 100%; + } + } + + .action-buttons { + flex-direction: column; + } +} + +// Print styles +@media print { + .demo-controls, + .performance-metrics, + .state-display, + .feature-demo { + display: none !important; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/enhanced-table-demo/enhanced-table-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/enhanced-table-demo/enhanced-table-demo.component.ts new file mode 100644 index 0000000..4518a03 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/enhanced-table-demo/enhanced-table-demo.component.ts @@ -0,0 +1,719 @@ +import { Component, ChangeDetectionStrategy, OnInit, TemplateRef, ViewChild, AfterViewInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { + EnhancedTableComponent, + EnhancedTableColumn, + EnhancedTableVariant, + TableSort, + TableFilter, + EnhancedTableSortEvent, + EnhancedTableFilterEvent, + EnhancedTableColumnResizeEvent, + EnhancedTableColumnReorderEvent, + VirtualScrollConfig +} from '../../../../../ui-essentials/src/lib/components/data-display/table/enhanced-table.component'; +import { StatusBadgeComponent } from '../../../../../ui-essentials/src/lib/components/feedback/status-badge.component'; + +interface Employee { + id: number; + name: string; + email: string; + department: string; + position: string; + salary: number; + status: 'active' | 'inactive' | 'pending'; + joinDate: string; + performance: number; + location: string; +} + +@Component({ + selector: 'ui-enhanced-table-demo', + standalone: true, + imports: [ + CommonModule, + FormsModule, + EnhancedTableComponent, + StatusBadgeComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

Enhanced Table Component Showcase

+

Enterprise-grade data table with virtual scrolling, column resizing/reordering, and advanced filtering.

+ + +
+

Basic Enhanced Table

+ + +
+ + +
+

Table Variants

+ +
+

Striped Enhanced Table

+ + +
+ +
+

Bordered Enhanced Table

+ + +
+ +
+

Minimal Enhanced Table

+ + +
+
+ + +
+

Virtual Scrolling ({{ largeDataset.length.toLocaleString() }} Records)

+

+ Virtual scrolling efficiently handles large datasets by only rendering visible rows. + Performance metrics are displayed below the table. +

+ + + + + @if (performanceMetrics) { +
+

Performance Metrics:

+
+
Render Time: {{ performanceMetrics.renderTime }}ms
+
Sort Time: {{ performanceMetrics.sortTime }}ms
+
Filter Time: {{ performanceMetrics.filterTime }}ms
+
Visible Rows: {{ performanceMetrics.visibleRows }}
+
+
+ } +
+ + +
+

Advanced Features with Custom Cell Templates

+ + + + + + + {{ value | titlecase }} + + + + + + {{ value | currency:'USD':'symbol':'1.0-0' }} + + + + +
+
+
+
+
+ {{ value }}% +
+
+ + +
+ + +
+
+
+ + +
+

Interactive Column Configuration

+
+ + + + +
+ + + +
+ + +
+

Usage Examples

+
+

Basic Enhanced Table:

+
<ui-enhanced-table 
+  [data]="employees"
+  [columns]="columns"
+  variant="default"
+  [enableColumnResize]="true"
+  [enableColumnReorder]="true"
+  [showFilters]="true"
+  [height]="500"
+  (sort)="handleSort($event)"
+  (filter)="handleFilter($event)">
+</ui-enhanced-table>
+ +

With Virtual Scrolling:

+
<ui-enhanced-table 
+  [data]="largeDataset"
+  [columns]="columns"
+  [virtualScrollConfig]="{{ '{' }} 
+    enabled: true, 
+    itemHeight: 48, 
+    bufferSize: 10 
+  {{ '}' }}"
+  [height]="600">
+</ui-enhanced-table>
+ +

Column Configuration:

+
columns: EnhancedTableColumn[] = [
+  {{ '{' }} 
+    key: 'name', 
+    label: 'Name', 
+    width: 150,
+    sortable: true,
+    filterable: true,
+    filterType: 'text',
+    resizable: true,
+    draggable: true
+  {{ '}' }},
+  {{ '{' }} 
+    key: 'salary', 
+    label: 'Salary', 
+    width: 120,
+    sortable: true,
+    filterable: true,
+    filterType: 'number',
+    resizable: true
+  {{ '}' }}
+];
+
+
+ + +
+

Interactive Demo Controls

+
+ + + + +
+ + @if (lastAction) { +
+ Last Action: {{ lastAction }} +
+ } +
+
+ `, + styles: [` + h2 { + color: hsl(279, 14%, 11%); + font-size: 2rem; + margin-bottom: 2rem; + border-bottom: 2px solid hsl(258, 100%, 47%); + padding-bottom: 0.5rem; + } + + h3 { + color: hsl(279, 14%, 25%); + font-size: 1.5rem; + margin-bottom: 1rem; + } + + h4 { + color: hsl(287, 12%, 35%); + font-size: 1.125rem; + margin-bottom: 0.75rem; + } + + section { + border: 1px solid hsl(289, 14%, 90%); + border-radius: 8px; + padding: 1.5rem; + background: hsl(286, 20%, 99%); + } + + pre { + font-size: 0.875rem; + line-height: 1.5; + margin: 0.5rem 0; + } + + code { + font-family: 'JetBrains Mono', monospace; + color: #d63384; + } + + label { + cursor: pointer; + font-weight: 500; + } + + input[type="checkbox"] { + cursor: pointer; + } + `] +}) +export class EnhancedTableDemoComponent implements OnInit, AfterViewInit { + @ViewChild('statusTemplate') statusTemplate!: TemplateRef; + @ViewChild('salaryTemplate') salaryTemplate!: TemplateRef; + @ViewChild('performanceTemplate') performanceTemplate!: TemplateRef; + @ViewChild('actionsTemplate') actionsTemplate!: TemplateRef; + + // Demo state + lastAction = ''; + performanceMetrics: any = null; + + // Configuration + enableResize = true; + enableReorder = true; + showTableFilters = true; + enableVirtual = false; + + // Cell templates + cellTemplates: Record> = {}; + + // Virtual scroll config + virtualConfig: VirtualScrollConfig = { + enabled: true, + itemHeight: 48, + buffer: 10 + }; + + configVirtualConfig: VirtualScrollConfig = { + enabled: false, + itemHeight: 48, + buffer: 10 + }; + + // Sample data + smallDataset: Employee[] = []; + largeDataset: Employee[] = []; + configDataset: Employee[] = []; + + // Column definitions + basicColumns: EnhancedTableColumn[] = [ + { key: 'name', label: 'Name', width: 150, sortable: true, filterable: true, filterType: 'text', resizable: true, draggable: true }, + { key: 'email', label: 'Email', width: 200, sortable: true, filterable: true, filterType: 'text', resizable: true, draggable: true }, + { key: 'department', label: 'Department', width: 140, sortable: true, filterable: true, filterType: 'select', filterOptions: this.getDepartmentOptions(), resizable: true, draggable: true }, + { key: 'position', label: 'Position', width: 160, sortable: true, filterable: true, filterType: 'text', resizable: true, draggable: true }, + { key: 'joinDate', label: 'Join Date', width: 120, sortable: true, filterable: true, filterType: 'date', resizable: true, draggable: true } + ]; + + performanceColumns: EnhancedTableColumn[] = [ + { key: 'name', label: 'Name', width: 120, sortable: true, filterable: true, filterType: 'text', resizable: true, draggable: true, sticky: true }, + { key: 'department', label: 'Department', width: 120, sortable: true, filterable: true, filterType: 'select', filterOptions: this.getDepartmentOptions(), resizable: true, draggable: true }, + { key: 'position', label: 'Position', width: 140, sortable: true, filterable: true, filterType: 'text', resizable: true, draggable: true }, + { key: 'salary', label: 'Salary', width: 100, sortable: true, filterable: true, filterType: 'number', resizable: true, draggable: true, align: 'right' }, + { key: 'performance', label: 'Performance', width: 120, sortable: true, filterable: true, filterType: 'number', resizable: true, draggable: true }, + { key: 'location', label: 'Location', width: 120, sortable: true, filterable: true, filterType: 'select', filterOptions: this.getLocationOptions(), resizable: true, draggable: true } + ]; + + advancedColumns: EnhancedTableColumn[] = [ + { key: 'name', label: 'Name', width: 150, sortable: true, filterable: true, filterType: 'text', resizable: true, draggable: true }, + { key: 'email', label: 'Email', width: 200, sortable: true, filterable: true, filterType: 'text', resizable: true, draggable: true }, + { key: 'status', label: 'Status', width: 100, sortable: true, filterable: true, filterType: 'select', filterOptions: this.getStatusOptions(), resizable: true, draggable: true, align: 'center' }, + { key: 'salary', label: 'Salary', width: 120, sortable: true, filterable: true, filterType: 'number', resizable: true, draggable: true, align: 'right' }, + { key: 'performance', label: 'Performance', width: 140, sortable: true, filterable: true, filterType: 'number', resizable: true, draggable: true }, + { key: 'actions', label: 'Actions', width: 100, resizable: true, align: 'center' } + ]; + + configColumns: EnhancedTableColumn[] = [ + { key: 'name', label: 'Name', width: 150, sortable: true, filterable: true, filterType: 'text', resizable: true, draggable: true }, + { key: 'department', label: 'Department', width: 140, sortable: true, filterable: true, filterType: 'select', filterOptions: this.getDepartmentOptions(), resizable: true, draggable: true }, + { key: 'salary', label: 'Salary', width: 120, sortable: true, filterable: true, filterType: 'number', resizable: true, draggable: true, align: 'right' }, + { key: 'joinDate', label: 'Join Date', width: 120, sortable: true, filterable: true, filterType: 'date', resizable: true, draggable: true } + ]; + + ngOnInit(): void { + this.initializeData(); + } + + ngAfterViewInit(): void { + // Set up cell templates after view init + setTimeout(() => { + this.cellTemplates = { + 'status': this.statusTemplate, + 'salary': this.salaryTemplate, + 'performance': this.performanceTemplate, + 'actions': this.actionsTemplate + }; + }); + } + + // Event handlers + handleSort(event: EnhancedTableSortEvent): void { + this.lastAction = `Sorted by ${event.sorting.map(s => `${s.column} (${s.direction})`).join(', ')}`; + console.log('Sort event:', event); + } + + handleFilter(event: EnhancedTableFilterEvent): void { + this.lastAction = `Filtered: ${event.filters.length} filters active`; + console.log('Filter event:', event); + } + + handleColumnResize(event: EnhancedTableColumnResizeEvent): void { + this.lastAction = `Resized column "${event.column}" to ${event.width}px`; + console.log('Column resize event:', event); + } + + handleColumnReorder(event: EnhancedTableColumnReorderEvent): void { + this.lastAction = `Reordered columns from index ${event.fromIndex} to ${event.toIndex}`; + console.log('Column reorder event:', event); + } + + handleRowClick(event: {row: Employee, index: number}): void { + this.lastAction = `Clicked row: ${event.row.name} (index: ${event.index})`; + console.log('Row click:', event); + } + + editEmployee(employee: Employee): void { + this.lastAction = `Editing employee: ${employee.name}`; + console.log('Edit employee:', employee); + } + + // Configuration updates + updateTableConfig(): void { + this.configVirtualConfig = { + ...this.configVirtualConfig, + enabled: this.enableVirtual + }; + this.lastAction = `Updated table configuration`; + } + + // Template helpers + getStatusVariant(status: string): 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'primary' | 'secondary' { + switch (status?.toLowerCase()) { + case 'active': + return 'success'; + case 'pending': + return 'warning'; + case 'inactive': + return 'danger'; + default: + return 'neutral'; + } + } + + getPerformanceColor(performance: number): string { + if (performance >= 90) return '#28a745'; + if (performance >= 80) return '#17a2b8'; + if (performance >= 70) return '#ffc107'; + return '#dc3545'; + } + + + // Filter options + getDepartmentOptions() { + return [ + { label: 'Engineering', value: 'Engineering' }, + { label: 'Marketing', value: 'Marketing' }, + { label: 'Sales', value: 'Sales' }, + { label: 'HR', value: 'HR' }, + { label: 'Finance', value: 'Finance' } + ]; + } + + getStatusOptions() { + return [ + { label: 'Active', value: 'active' }, + { label: 'Inactive', value: 'inactive' }, + { label: 'Pending', value: 'pending' } + ]; + } + + getLocationOptions() { + return [ + { label: 'New York', value: 'New York' }, + { label: 'San Francisco', value: 'San Francisco' }, + { label: 'London', value: 'London' }, + { label: 'Toronto', value: 'Toronto' }, + { label: 'Sydney', value: 'Sydney' } + ]; + } + + // Demo actions + addRandomEmployee(): void { + const names = ['John Doe', 'Jane Smith', 'Mike Johnson', 'Sarah Wilson', 'Tom Anderson', 'Lisa Brown', 'David Clark', 'Emma Davis']; + const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance']; + const positions = ['Manager', 'Developer', 'Analyst', 'Coordinator', 'Specialist']; + const statuses: ('active' | 'inactive' | 'pending')[] = ['active', 'inactive', 'pending']; + const locations = ['New York', 'San Francisco', 'London', 'Toronto', 'Sydney']; + + const newEmployee: Employee = { + id: Math.max(...this.smallDataset.map(e => e.id), 0) + 1, + name: names[Math.floor(Math.random() * names.length)], + email: `user${Date.now()}@company.com`, + department: departments[Math.floor(Math.random() * departments.length)], + position: positions[Math.floor(Math.random() * positions.length)], + salary: Math.floor(Math.random() * 100000) + 50000, + status: statuses[Math.floor(Math.random() * statuses.length)], + joinDate: new Date(2020 + Math.floor(Math.random() * 5), Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1).toISOString().split('T')[0], + performance: Math.floor(Math.random() * 41) + 60, + location: locations[Math.floor(Math.random() * locations.length)] + }; + + this.smallDataset = [...this.smallDataset, newEmployee]; + this.configDataset = [...this.configDataset, newEmployee]; + this.lastAction = `Added new employee: ${newEmployee.name}`; + } + + generateLargeDataset(): void { + this.largeDataset = this.createLargeDataset(10000); + this.performanceMetrics = { + renderTime: Math.floor(Math.random() * 50) + 10, + sortTime: 0, + filterTime: 0, + visibleRows: Math.min(10, this.largeDataset.length) + }; + this.lastAction = `Generated ${this.largeDataset.length.toLocaleString()} employee records`; + } + + clearAllEmployees(): void { + this.smallDataset = []; + this.largeDataset = []; + this.configDataset = []; + this.performanceMetrics = null; + this.lastAction = 'Cleared all employee data'; + } + + resetDemo(): void { + this.initializeData(); + this.lastAction = 'Demo reset to initial state'; + } + + // Data generation + private initializeData(): void { + this.smallDataset = this.createSmallDataset(); + this.configDataset = [...this.smallDataset]; + this.largeDataset = this.createLargeDataset(50000); + this.performanceMetrics = { + renderTime: Math.floor(Math.random() * 30) + 10, + sortTime: 0, + filterTime: 0, + visibleRows: Math.min(10, this.largeDataset.length) + }; + } + + private createSmallDataset(): Employee[] { + return [ + { + id: 1, + name: 'Alice Johnson', + email: 'alice.johnson@company.com', + department: 'Engineering', + position: 'Senior Developer', + salary: 95000, + status: 'active', + joinDate: '2022-01-15', + performance: 92, + location: 'San Francisco' + }, + { + id: 2, + name: 'Bob Smith', + email: 'bob.smith@company.com', + department: 'Marketing', + position: 'Marketing Manager', + salary: 78000, + status: 'active', + joinDate: '2021-06-10', + performance: 85, + location: 'New York' + }, + { + id: 3, + name: 'Carol Williams', + email: 'carol.williams@company.com', + department: 'Sales', + position: 'Sales Representative', + salary: 65000, + status: 'pending', + joinDate: '2023-03-22', + performance: 78, + location: 'London' + }, + { + id: 4, + name: 'David Brown', + email: 'david.brown@company.com', + department: 'HR', + position: 'HR Specialist', + salary: 58000, + status: 'active', + joinDate: '2022-08-05', + performance: 88, + location: 'Toronto' + }, + { + id: 5, + name: 'Eve Davis', + email: 'eve.davis@company.com', + department: 'Finance', + position: 'Financial Analyst', + salary: 72000, + status: 'active', + joinDate: '2021-11-18', + performance: 91, + location: 'Sydney' + }, + { + id: 6, + name: 'Frank Miller', + email: 'frank.miller@company.com', + department: 'Engineering', + position: 'DevOps Engineer', + salary: 88000, + status: 'inactive', + joinDate: '2020-04-12', + performance: 82, + location: 'San Francisco' + } + ]; + } + + private createLargeDataset(count: number): Employee[] { + const dataset: Employee[] = []; + const firstNames = ['Alice', 'Bob', 'Carol', 'David', 'Eve', 'Frank', 'Grace', 'Henry', 'Ivy', 'Jack']; + const lastNames = ['Johnson', 'Smith', 'Williams', 'Brown', 'Davis', 'Miller', 'Wilson', 'Moore', 'Taylor', 'Anderson']; + const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance']; + const positions = ['Manager', 'Senior Developer', 'Developer', 'Analyst', 'Coordinator', 'Specialist', 'Representative']; + const statuses: ('active' | 'inactive' | 'pending')[] = ['active', 'inactive', 'pending']; + const locations = ['New York', 'San Francisco', 'London', 'Toronto', 'Sydney']; + + for (let i = 0; i < count; i++) { + const firstName = firstNames[Math.floor(Math.random() * firstNames.length)]; + const lastName = lastNames[Math.floor(Math.random() * lastNames.length)]; + + dataset.push({ + id: i + 1000, + name: `${firstName} ${lastName}`, + email: `${firstName.toLowerCase()}.${lastName.toLowerCase()}@company.com`, + department: departments[Math.floor(Math.random() * departments.length)], + position: positions[Math.floor(Math.random() * positions.length)], + salary: Math.floor(Math.random() * 100000) + 50000, + status: statuses[Math.floor(Math.random() * statuses.length)], + joinDate: new Date(2020 + Math.floor(Math.random() * 5), Math.floor(Math.random() * 12), Math.floor(Math.random() * 28) + 1).toISOString().split('T')[0], + performance: Math.floor(Math.random() * 41) + 60, + location: locations[Math.floor(Math.random() * locations.length)] + }); + } + + return dataset; + } + + deleteEmployee(id: number): void { + this.smallDataset = this.smallDataset.filter(emp => emp.id !== id); + this.configDataset = this.configDataset.filter(emp => emp.id !== id); + this.lastAction = `Deleted employee with ID: ${id}`; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/fab-menu-demo/fab-menu-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/fab-menu-demo/fab-menu-demo.component.scss new file mode 100644 index 0000000..d4c3988 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/fab-menu-demo/fab-menu-demo.component.scss @@ -0,0 +1,201 @@ +@use '../../../../../shared-ui/src/styles/semantic' as *; + +.demo-container { + padding: $semantic-spacing-layout-section-lg; + max-width: 1200px; + margin: 0 auto; + + h2 { + font-family: map-get($semantic-typography-heading-h2, font-family); + font-size: map-get($semantic-typography-heading-h2, font-size); + font-weight: map-get($semantic-typography-heading-h2, font-weight); + line-height: map-get($semantic-typography-heading-h2, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-content-heading; + } + + p { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-secondary; + margin-bottom: $semantic-spacing-content-paragraph; + } +} + +.demo-section { + margin-bottom: $semantic-spacing-layout-section-lg; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-card-radius; + box-shadow: $semantic-shadow-elevation-1; + + h3 { + font-family: map-get($semantic-typography-heading-h3, font-family); + font-size: map-get($semantic-typography-heading-h3, font-size); + font-weight: map-get($semantic-typography-heading-h3, font-weight); + line-height: map-get($semantic-typography-heading-h3, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-content-heading; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + padding-bottom: $semantic-spacing-component-sm; + } +} + +.demo-row { + display: flex; + flex-wrap: wrap; + gap: $semantic-spacing-grid-gap-lg; + align-items: flex-start; + + &--spaced { + justify-content: space-around; + + .demo-example { + min-width: 200px; + text-align: center; + } + } +} + +.demo-example { + display: flex; + flex-direction: column; + align-items: center; + gap: $semantic-spacing-component-md; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-input-radius; + min-height: 120px; + position: relative; + + h4 { + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + color: $semantic-color-text-primary; + margin: 0 0 $semantic-spacing-component-sm 0; + text-align: center; + } +} + +.demo-button { + padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x; + border: $semantic-border-width-1 solid $semantic-color-primary; + border-radius: $semantic-border-button-radius; + background: $semantic-color-primary; + color: $semantic-color-on-primary; + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + line-height: map-get($semantic-typography-button-medium, line-height); + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover:not(:disabled) { + box-shadow: $semantic-shadow-button-hover; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active:not(:disabled) { + transform: translateY(1px); + box-shadow: $semantic-shadow-button-rest; + } + + &--secondary { + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + border-color: $semantic-color-border-primary; + + &:hover:not(:disabled) { + background: $semantic-color-surface-secondary; + } + } +} + +.demo-log { + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-radius: $semantic-border-input-radius; + padding: $semantic-spacing-component-md; + max-height: 300px; + overflow-y: auto; + margin-bottom: $semantic-spacing-component-md; + + &__empty { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-tertiary; + text-align: center; + font-style: italic; + margin: 0; + } + + &__entry { + display: flex; + gap: $semantic-spacing-component-sm; + padding: $semantic-spacing-component-xs; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + + &:last-child { + border-bottom: none; + } + } + + &__time { + font-family: $semantic-typography-font-family-mono; + font-size: map-get($semantic-typography-caption, font-size); + color: $semantic-color-text-tertiary; + flex-shrink: 0; + min-width: 80px; + } + + &__message { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-primary; + } +} + +// Responsive design +@media (max-width: 768px) { + .demo-container { + padding: $semantic-spacing-component-md; + } + + .demo-row { + flex-direction: column; + align-items: stretch; + + &--spaced { + align-items: center; + } + } + + .demo-example { + min-height: auto; + padding: $semantic-spacing-component-md; + } + + .demo-section { + padding: $semantic-spacing-component-md; + } +} + +// Ensure positioned FAB menus are visible above demo content +:host { + position: relative; + z-index: 1; +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/fab-menu-demo/fab-menu-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/fab-menu-demo/fab-menu-demo.component.ts new file mode 100644 index 0000000..45c2b9a --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/fab-menu-demo/fab-menu-demo.component.ts @@ -0,0 +1,286 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FabMenuComponent, FabMenuDirection, FabMenuItem, FabMenuPosition, FabMenuSize, FabMenuVariant } from '../../../../../ui-essentials/src/lib/components/buttons/fab-menu'; + +@Component({ + selector: 'ui-fab-menu-demo', + standalone: true, + imports: [CommonModule, FabMenuComponent], + template: ` +
+

Floating Action Button Menu Demo

+

An interactive floating action button menu that expands to reveal multiple action items.

+ + +
+

Sizes

+
+ @for (size of sizes; track size) { +
+

{{ size | titlecase }}

+ + +
+ } +
+
+ + +
+

Color Variants

+
+ @for (variant of variants; track variant) { +
+

{{ variant | titlecase }}

+ + +
+ } +
+
+ + +
+

Direction Variants

+
+ @for (direction of directions; track direction) { +
+

{{ direction | titlecase }}

+ + +
+ } +
+
+ + +
+

Fixed Positions

+

These examples show fixed positioning variants (click to activate):

+
+ @for (position of positions; track position) { + + } +
+ + @if (activePositionDemo) { + + + } +
+ + +
+

States

+
+
+

Disabled

+ + +
+ +
+

No Backdrop

+ + +
+ +
+

Stay Open

+ + +
+
+
+ + +
+

Advanced Examples

+ +
+

Mixed Item Variants

+ + +
+ +
+

Large Menu with Icons

+ + +
+
+ + +
+

Event Log

+
+ @if (eventLog.length === 0) { +

No events yet. Interact with the FAB menus above.

+ } @else { + @for (event of eventLog.slice(-10); track $index) { +
+ {{ event.timestamp | date:'HH:mm:ss' }} + {{ event.message }} +
+ } + } +
+ +
+
+ `, + styleUrl: './fab-menu-demo.component.scss' +}) +export class FabMenuDemoComponent { + sizes: FabMenuSize[] = ['sm', 'md', 'lg']; + variants: FabMenuVariant[] = ['primary', 'secondary', 'success', 'danger']; + directions: FabMenuDirection[] = ['up', 'down', 'left', 'right']; + positions: FabMenuPosition[] = ['bottom-right', 'bottom-left', 'top-right', 'top-left']; + + activePositionDemo: FabMenuPosition | null = null; + eventLog: Array<{timestamp: Date, message: string}> = []; + + // Icons (using simple text for demo - could be replaced with FontAwesome or other icon library) + plusIcon = '+'; + closeIcon = '×'; + menuIcon = ''; + settingsIcon = ''; + appsIcon = ''; + + basicMenuItems: FabMenuItem[] = [ + { id: 'action1', label: 'Action 1', icon: '📝' }, + { id: 'action2', label: 'Action 2', icon: '📁' }, + { id: 'action3', label: 'Action 3', icon: '📊' } + ]; + + positionMenuItems: FabMenuItem[] = [ + { id: 'close', label: 'Close Menu', icon: '×' }, + { id: 'home', label: 'Go Home', icon: '🏠' }, + { id: 'profile', label: 'Profile', icon: '👤' }, + { id: 'settings', label: 'Settings', icon: '' } + ]; + + mixedMenuItems: FabMenuItem[] = [ + { id: 'save', label: 'Save', variant: 'success', icon: '💾' }, + { id: 'edit', label: 'Edit', variant: 'primary', icon: '✏️' }, + { id: 'delete', label: 'Delete', variant: 'danger', icon: '🗑️' }, + { id: 'disabled', label: 'Disabled', disabled: true, icon: '🚫' } + ]; + + iconMenuItems: FabMenuItem[] = [ + { id: 'email', label: 'Email', icon: '📧' }, + { id: 'calendar', label: 'Calendar', icon: '📅' }, + { id: 'documents', label: 'Documents', icon: '📄' }, + { id: 'photos', label: 'Photos', icon: '📸' }, + { id: 'music', label: 'Music', icon: '🎵' }, + { id: 'videos', label: 'Videos', icon: '🎬' } + ]; + + getVariantMenuItems(variant: FabMenuVariant): FabMenuItem[] { + return [ + { id: `${variant}1`, label: `${variant} Item 1`, variant }, + { id: `${variant}2`, label: `${variant} Item 2`, variant }, + { id: `${variant}3`, label: `${variant} Item 3`, variant } + ]; + } + + handleItemClick(event: {item: FabMenuItem, event: Event}): void { + this.logEvent(`Item clicked: ${event.item.label} (${event.item.id})`); + } + + togglePositionDemo(position: FabMenuPosition): void { + if (this.activePositionDemo === position) { + this.activePositionDemo = null; + this.logEvent(`Closed ${position} positioned menu`); + } else { + this.activePositionDemo = position; + this.logEvent(`Opened ${position} positioned menu`); + } + } + + handlePositionItemClick(event: {item: FabMenuItem, event: Event}): void { + if (event.item.id === 'close') { + this.activePositionDemo = null; + this.logEvent('Closed positioned menu via menu item'); + } else { + this.logEvent(`Position menu item clicked: ${event.item.label}`); + } + } + + onMenuOpened(): void { + this.logEvent('Menu opened'); + } + + onMenuClosed(): void { + this.logEvent('Menu closed'); + } + + logEvent(message: string): void { + this.eventLog.push({ + timestamp: new Date(), + message + }); + } + + clearLog(): void { + this.eventLog = []; + this.logEvent('Event log cleared'); + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/floating-toolbar-demo/floating-toolbar-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/floating-toolbar-demo/floating-toolbar-demo.component.scss new file mode 100644 index 0000000..4d7e3a5 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/floating-toolbar-demo/floating-toolbar-demo.component.scss @@ -0,0 +1,509 @@ +@use '../../../../../shared-ui/src/styles/semantic/index' as *; + +.demo-container { + padding: $semantic-spacing-layout-section-md; + max-width: 1200px; + margin: 0 auto; +} + +.demo-section { + margin-bottom: $semantic-spacing-layout-section-lg; + + h3 { + font-family: map-get($semantic-typography-heading-h3, font-family); + font-size: map-get($semantic-typography-heading-h3, font-size); + font-weight: map-get($semantic-typography-heading-h3, font-weight); + line-height: map-get($semantic-typography-heading-h3, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-layout-section-sm; + } +} + +.demo-subsection { + margin-bottom: $semantic-spacing-layout-section-md; + + h4 { + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-component-md; + } +} + +.demo-row { + display: flex; + gap: $semantic-spacing-component-md; + flex-wrap: wrap; + align-items: flex-start; + + &--center { + justify-content: center; + } +} + +.demo-item { + display: flex; + flex-direction: column; + align-items: center; + gap: $semantic-spacing-component-sm; +} + +.demo-trigger-button { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border: none; + border-radius: $semantic-border-button-radius; + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + line-height: map-get($semantic-typography-button-medium, line-height); + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + min-height: $semantic-sizing-button-height-md; + + &:hover { + background: $semantic-color-primary; + opacity: $semantic-opacity-hover; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + transform: translateY(1px); + } +} + +.demo-position-container { + display: grid; + grid-template-areas: + ". top ." + "left center right" + ". bottom ."; + gap: $semantic-spacing-component-lg; + padding: $semantic-spacing-layout-section-md; + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-lg; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; +} + +.demo-position-button { + padding: $semantic-spacing-component-sm; + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + min-width: 80px; + text-align: center; + font-family: map-get($semantic-typography-button-small, font-family); + font-size: map-get($semantic-typography-button-small, font-size); + font-weight: map-get($semantic-typography-button-small, font-weight); + line-height: map-get($semantic-typography-button-small, line-height); + + &--top { + grid-area: top; + } + + &--bottom { + grid-area: bottom; + } + + &--left { + grid-area: left; + } + + &--right { + grid-area: right; + } + + &:hover { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-border-primary; + box-shadow: $semantic-shadow-elevation-1; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } +} + +.demo-context-area { + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-md; + margin-bottom: $semantic-spacing-component-md; + min-height: 120px; + position: relative; + cursor: pointer; + + p { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + margin: 0 0 $semantic-spacing-content-paragraph 0; + + &:last-child { + margin-bottom: 0; + } + } + + img { + max-width: 200px; + height: auto; + display: block; + margin: 0 auto $semantic-spacing-component-sm auto; + border-radius: $semantic-border-radius-sm; + } + + &:hover { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-border-primary; + } +} + +.demo-hint { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + text-align: center; + font-style: italic; +} + +.demo-editor { + min-height: 120px; + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-input-radius; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + cursor: text; + + &:focus { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + border-color: $semantic-color-primary; + } + + p { + margin: 0 0 $semantic-spacing-content-paragraph 0; + + &:last-child { + margin-bottom: 0; + } + } + + strong { + font-weight: $semantic-typography-font-weight-bold; + } + + em { + font-style: italic; + } + + u { + text-decoration: underline; + } +} + +.demo-table { + width: 100%; + border-collapse: collapse; + background: $semantic-color-surface-primary; + border-radius: $semantic-border-radius-lg; + overflow: hidden; + box-shadow: $semantic-shadow-elevation-1; + + th, + td { + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + text-align: left; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + } + + th { + background: $semantic-color-surface-secondary; + color: $semantic-color-text-primary; + font-weight: $semantic-typography-font-weight-semibold; + position: sticky; + top: 0; + } + + td { + color: $semantic-color-text-primary; + } +} + +.demo-table-row { + cursor: pointer; + transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background: $semantic-color-surface-elevated; + } + + &--selected { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + + td { + color: $semantic-color-on-primary; + } + } + + &:last-child td { + border-bottom: none; + } +} + +.demo-table-action { + background: none; + border: none; + color: $semantic-color-text-secondary; + font-size: $semantic-typography-font-size-lg; + cursor: pointer; + padding: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-sm; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + .demo-table-row--selected & { + color: $semantic-color-on-primary; + + &:hover { + background: rgba(255, 255, 255, 0.1); + } + } +} + +.demo-controls { + display: flex; + flex-direction: column; + gap: $semantic-spacing-component-md; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-lg; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; +} + +.demo-control-group { + display: flex; + flex-wrap: wrap; + gap: $semantic-spacing-component-md; + + label { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + cursor: pointer; + user-select: none; + + input[type="checkbox"] { + margin: 0; + cursor: pointer; + } + } +} + +.demo-output { + margin-top: $semantic-spacing-layout-section-md; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-lg; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + + h4 { + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + color: $semantic-color-text-primary; + margin: 0 0 $semantic-spacing-component-md 0; + } +} + +.demo-log { + max-height: 200px; + overflow-y: auto; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-md; + padding: $semantic-spacing-component-sm; + + // Custom scrollbar styling + scrollbar-width: thin; + scrollbar-color: $semantic-color-border-subtle $semantic-color-surface-secondary; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-sm; + } + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-subtle; + border-radius: $semantic-border-radius-sm; + + &:hover { + background: $semantic-color-border-primary; + } + } +} + +.demo-log-entry { + font-family: $semantic-typography-font-family-mono; + font-size: map-get($semantic-typography-body-small, font-size); + color: $semantic-color-text-primary; + padding: $semantic-spacing-component-xs 0; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + + &:last-child { + border-bottom: none; + } + + &:first-child { + color: $semantic-color-success; + font-weight: $semantic-typography-font-weight-medium; + } +} + +.demo-log-empty { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + color: $semantic-color-text-secondary; + text-align: center; + padding: $semantic-spacing-component-md; + font-style: italic; +} + +// Responsive Design +@media (max-width: $semantic-breakpoint-md - 1) { + .demo-container { + padding: $semantic-spacing-layout-section-sm; + } + + .demo-row { + gap: $semantic-spacing-component-sm; + } + + .demo-position-container { + padding: $semantic-spacing-component-lg; + gap: $semantic-spacing-component-md; + } + + .demo-control-group { + flex-direction: column; + gap: $semantic-spacing-component-sm; + } +} + +@media (max-width: $semantic-breakpoint-sm - 1) { + .demo-container { + padding: $semantic-spacing-component-md; + } + + .demo-section { + margin-bottom: $semantic-spacing-layout-section-md; + } + + .demo-row { + flex-direction: column; + gap: $semantic-spacing-component-sm; + } + + .demo-context-area { + padding: $semantic-spacing-component-md; + min-height: 100px; + } + + .demo-editor { + min-height: 100px; + padding: $semantic-spacing-component-sm; + } + + .demo-table { + font-size: map-get($semantic-typography-body-small, font-size); + + th, + td { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + } + } + + .demo-position-container { + grid-template-areas: + "top" + "left" + "center" + "right" + "bottom"; + gap: $semantic-spacing-component-sm; + padding: $semantic-spacing-component-md; + } + + .demo-position-button { + min-width: 60px; + } +} + +// High contrast support +@media (prefers-contrast: high) { + .demo-trigger-button, + .demo-position-button, + .demo-table, + .demo-context-area, + .demo-editor, + .demo-controls, + .demo-output, + .demo-log { + border-width: $semantic-border-width-2; + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .demo-trigger-button, + .demo-position-button, + .demo-table-row, + .demo-table-action, + .demo-context-area { + transition: none; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/floating-toolbar-demo/floating-toolbar-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/floating-toolbar-demo/floating-toolbar-demo.component.ts new file mode 100644 index 0000000..ca2521f --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/floating-toolbar-demo/floating-toolbar-demo.component.ts @@ -0,0 +1,670 @@ +import { Component, ViewChild, AfterViewInit } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FloatingToolbarComponent, ToolbarAction, FloatingToolbarConfig } from '../../../../../ui-essentials/src/lib/components/overlays/floating-toolbar/floating-toolbar.component'; + +@Component({ + selector: 'ui-floating-toolbar-demo', + standalone: true, + imports: [CommonModule, FormsModule, FloatingToolbarComponent], + template: ` +
+

Floating Toolbar Demo

+ + +
+

Size Variants

+
+ @for (size of sizes; track size) { +
+ +
+ } +
+
+ + +
+

Style Variants

+
+ @for (variant of variants; track variant) { +
+ +
+ } +
+
+ + +
+

Position Variants

+
+
+ @for (position of positions; track position) { + + } +
+
+
+ + +
+

Context-Sensitive Actions

+
+
+

+ Select this text to see a text editing toolbar with cut, copy, paste, and formatting options. + The toolbar will appear automatically when you make a selection. +

+
+
+ Demo Image +

Right-click the image for image editing options

+
+
+
+ + +
+

Auto-Hide Behavior

+
+ +
+
+ + +
+

Custom Actions

+
+ +
+ +
+

Action Log:

+
+ @for (logEntry of actionLog; track $index) { +
{{ logEntry }}
+ } + @if (actionLog.length === 0) { +
No actions performed yet...
+ } +
+
+
+ + +
+

Interactive Examples

+ + +
+

Editable Text with Formatting Toolbar

+
+

This is an editable text area. Select text to see formatting options in the floating toolbar.

+

Bold text, italic text, and underlined text are supported.

+
+
+ + +
+

Data Table with Row Actions

+ + + + + + + + + + + @for (user of sampleUsers; track user.id) { + + + + + + + } + +
NameEmailStatusActions
{{ user.name }}{{ user.email }}{{ user.status }} + +
+
+
+ + +
+

Configuration Options

+
+
+ + + + + +
+ + +
+
+
+ + + + + + + `, + styleUrl: './floating-toolbar-demo.component.scss' +}) +export class FloatingToolbarDemoComponent implements AfterViewInit { + @ViewChild('floatingToolbar') floatingToolbar!: FloatingToolbarComponent; + @ViewChild('textSelectionToolbar') textSelectionToolbar!: FloatingToolbarComponent; + + // Demo Data + sizes = ['sm', 'md', 'lg'] as const; + variants = ['default', 'elevated', 'floating', 'compact', 'contextual'] as const; + positions = ['top', 'bottom', 'left', 'right'] as const; + + sampleUsers = [ + { id: 1, name: 'Alice Johnson', email: 'alice@example.com', status: 'Active' }, + { id: 2, name: 'Bob Smith', email: 'bob@example.com', status: 'Pending' }, + { id: 3, name: 'Carol Davis', email: 'carol@example.com', status: 'Inactive' }, + ]; + + // Demo State + actionLog: string[] = []; + selectedUserId: number | null = null; + currentConfig: FloatingToolbarConfig = { + visible: false, + size: 'md', + variant: 'default', + position: 'top', + autoHide: false, + hideDelay: 3000, + backdrop: false, + escapeClosable: true, + label: '', + showLabel: false, + showShortcuts: false + }; + + demoConfig = { + backdrop: false, + autoHide: false, + escapeClosable: true + }; + + currentActions: ToolbarAction[] = []; + + // Action Sets + basicActions: ToolbarAction[] = [ + { + id: 'edit', + label: 'Edit', + icon: 'fas fa-edit', + callback: (action, event) => this.logAction('Edit clicked') + }, + { + id: 'delete', + label: 'Delete', + icon: 'fas fa-trash', + callback: (action, event) => this.logAction('Delete clicked') + }, + { + id: 'share', + label: 'Share', + icon: 'fas fa-share', + callback: (action, event) => this.logAction('Share clicked') + } + ]; + + textActions: ToolbarAction[] = [ + { + id: 'cut', + label: 'Cut', + icon: 'fas fa-cut', + shortcut: 'Ctrl+X', + callback: (action, event) => this.executeTextAction('cut') + }, + { + id: 'copy', + label: 'Copy', + icon: 'fas fa-copy', + shortcut: 'Ctrl+C', + callback: (action, event) => this.executeTextAction('copy') + }, + { + id: 'paste', + label: 'Paste', + icon: 'fas fa-paste', + shortcut: 'Ctrl+V', + callback: (action, event) => this.executeTextAction('paste') + }, + { + id: 'divider1', + label: '', + divider: true + }, + { + id: 'bold', + label: 'Bold', + icon: 'fas fa-bold', + shortcut: 'Ctrl+B', + callback: (action, event) => this.executeTextAction('bold') + }, + { + id: 'italic', + label: 'Italic', + icon: 'fas fa-italic', + shortcut: 'Ctrl+I', + callback: (action, event) => this.executeTextAction('italic') + }, + { + id: 'underline', + label: 'Underline', + icon: 'fas fa-underline', + shortcut: 'Ctrl+U', + callback: (action, event) => this.executeTextAction('underline') + } + ]; + + imageActions: ToolbarAction[] = [ + { + id: 'resize', + label: 'Resize', + icon: 'fas fa-expand-arrows-alt', + callback: (action, event) => this.logAction('Resize image') + }, + { + id: 'crop', + label: 'Crop', + icon: 'fas fa-crop', + callback: (action, event) => this.logAction('Crop image') + }, + { + id: 'filters', + label: 'Filters', + icon: 'fas fa-filter', + callback: (action, event) => this.logAction('Apply filters') + }, + { + id: 'divider2', + label: '', + divider: true + }, + { + id: 'download', + label: 'Download', + icon: 'fas fa-download', + callback: (action, event) => this.logAction('Download image') + } + ]; + + customActions: ToolbarAction[] = [ + { + id: 'action1', + label: 'Action 1', + icon: 'fas fa-star', + tooltip: 'This is action 1', + callback: (action, event) => this.logAction('Custom Action 1 executed') + }, + { + id: 'action2', + label: 'Action 2', + icon: 'fas fa-heart', + tooltip: 'This is action 2 with a longer tooltip', + disabled: false, + callback: (action, event) => this.logAction('Custom Action 2 executed') + }, + { + id: 'divider3', + label: '', + divider: true + }, + { + id: 'action3', + label: 'Disabled Action', + icon: 'fas fa-ban', + disabled: true, + tooltip: 'This action is disabled', + callback: (action, event) => this.logAction('This should not execute') + }, + { + id: 'action4', + label: 'Toggle Action', + icon: 'fas fa-toggle-on', + callback: (action, event) => this.toggleAction(action) + } + ]; + + userActions: ToolbarAction[] = [ + { + id: 'view', + label: 'View Details', + icon: 'fas fa-eye', + callback: (action, event) => this.logAction('View user details') + }, + { + id: 'edit-user', + label: 'Edit User', + icon: 'fas fa-edit', + callback: (action, event) => this.logAction('Edit user') + }, + { + id: 'delete-user', + label: 'Delete User', + icon: 'fas fa-trash', + callback: (action, event) => this.logAction('Delete user') + } + ]; + + ngAfterViewInit(): void { + // Setup text selection toolbar + this.setupTextSelectionToolbar(); + } + + showToolbar(id: string, triggerElement: HTMLElement, config: Partial): void { + console.log('Demo: showToolbar called', { id, config, basicActions: this.basicActions }); + this.currentConfig = { + ...this.currentConfig, + ...config, + visible: true + }; + this.currentActions = [...this.basicActions]; + console.log('Demo: config and actions set', { + currentConfig: this.currentConfig, + currentActions: this.currentActions + }); + this.floatingToolbar.setAnchorElement(triggerElement); + } + + showAutoHideToolbar(triggerElement: HTMLElement): void { + this.currentConfig = { + ...this.currentConfig, + visible: true, + autoHide: true, + hideDelay: 3000, + label: 'Auto-hide in 3 seconds' + }; + this.currentActions = [...this.basicActions]; + this.floatingToolbar.setAnchorElement(triggerElement); + } + + showCustomActionsToolbar(triggerElement: HTMLElement): void { + this.currentConfig = { + ...this.currentConfig, + visible: true, + autoHide: false, + showShortcuts: true, + label: 'Custom Actions' + }; + this.currentActions = [...this.customActions]; + this.floatingToolbar.setAnchorElement(triggerElement); + } + + showContextualToolbar(event: MouseEvent, actions: ToolbarAction[], context: string): void { + event.preventDefault(); + + // Create temporary anchor at mouse position + const tempAnchor = { + getBoundingClientRect: () => ({ + top: event.clientY, + left: event.clientX, + right: event.clientX, + bottom: event.clientY, + width: 0, + height: 0 + } as DOMRect) + } as HTMLElement; + + this.currentConfig = { + ...this.currentConfig, + visible: true, + variant: 'contextual', + position: 'bottom', + autoHide: true, + hideDelay: 5000, + label: `${context} actions` + }; + this.currentActions = [...actions]; + this.floatingToolbar.setAnchorElement(tempAnchor); + } + + showConfigurableToolbar(triggerElement: HTMLElement): void { + this.currentConfig = { + ...this.currentConfig, + ...this.demoConfig, + visible: true, + label: 'Configured Toolbar' + }; + this.currentActions = [...this.basicActions]; + this.floatingToolbar.setAnchorElement(triggerElement); + } + + updateDemoConfig(): void { + // Update the demo configuration + Object.assign(this.currentConfig, this.demoConfig); + } + + handleTextSelection(element: HTMLElement): void { + const selection = window.getSelection(); + if (selection && selection.toString().trim()) { + const range = selection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + if (rect.width > 0 && rect.height > 0) { + // Create anchor at selection + const selectionAnchor = { + getBoundingClientRect: () => rect + } as HTMLElement; + + this.textSelectionToolbar.setAnchorElement(selectionAnchor); + this.textSelectionToolbar.show(); + } + } else { + this.textSelectionToolbar.hide(); + } + } + + selectUser(userId: number, rowElement: HTMLElement): void { + this.selectedUserId = userId; + } + + showUserActions(event: Event, rowElement: HTMLElement, user: any): void { + event.stopPropagation(); + + this.currentConfig = { + ...this.currentConfig, + visible: true, + variant: 'elevated', + position: 'left', + label: `Actions for ${user.name}` + }; + this.currentActions = [...this.userActions]; + this.floatingToolbar.setAnchorElement(rowElement); + } + + onActionClicked(event: { action: ToolbarAction; event: Event }): void { + this.logAction(`Action clicked: ${event.action.label}`); + } + + onTextActionClicked(event: { action: ToolbarAction; event: Event }): void { + this.logAction(`Text action: ${event.action.label}`); + } + + onToolbarVisibilityChange(visible: boolean): void { + this.currentConfig.visible = visible; + } + + onToolbarHidden(): void { + this.logAction('Toolbar hidden'); + } + + executeTextAction(command: string): void { + try { + document.execCommand(command, false); + this.logAction(`Text formatting: ${command}`); + } catch (error) { + this.logAction(`Text formatting failed: ${command}`); + } + } + + toggleAction(action: ToolbarAction): void { + const isToggled = action.icon === 'fas fa-toggle-on'; + action.icon = isToggled ? 'fas fa-toggle-off' : 'fas fa-toggle-on'; + this.logAction(`Toggle action: ${isToggled ? 'OFF' : 'ON'}`); + } + + private setupTextSelectionToolbar(): void { + document.addEventListener('selectionchange', () => { + const selection = window.getSelection(); + if (!selection || !selection.toString().trim()) { + this.textSelectionToolbar.hide(); + } + }); + } + + getConfigValue(key: K): NonNullable { + const defaultValues: Required = { + size: 'md', + variant: 'default', + position: 'top', + autoPosition: true, + visible: false, + actions: [], + context: undefined as any, + trigger: 'manual', + autoHide: false, + hideDelay: 3000, + showAnimation: 'slide-fade', + backdrop: false, + backdropClosable: true, + escapeClosable: true, + offset: 12, + label: '', + showLabel: false, + showShortcuts: false + }; + + const value = this.currentConfig[key]; + return (value !== undefined ? value : defaultValues[key]) as NonNullable; + } + + private logAction(message: string): void { + const timestamp = new Date().toLocaleTimeString(); + this.actionLog.unshift(`[${timestamp}] ${message}`); + + // Keep only the last 10 entries + if (this.actionLog.length > 10) { + this.actionLog = this.actionLog.slice(0, 10); + } + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/icon-button-demo/icon-button-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/icon-button-demo/icon-button-demo.component.scss new file mode 100644 index 0000000..94cf2ee --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/icon-button-demo/icon-button-demo.component.scss @@ -0,0 +1,21 @@ +// Demo-specific styles for the icon button showcase +:host { + display: block; +} + +// Danger button styling for destructive actions demo +::ng-deep .danger-button { + &.ui-icon-button--outlined { + color: #dc3545; + border-color: #dc3545; + + &:hover:not(:disabled) { + background-color: rgba(220, 53, 69, 0.1); + border-color: #dc3545; + } + + &:focus-visible { + outline-color: #dc3545; + } + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/icon-button-demo/icon-button-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/icon-button-demo/icon-button-demo.component.ts new file mode 100644 index 0000000..c970f71 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/icon-button-demo/icon-button-demo.component.ts @@ -0,0 +1,305 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { IconButtonComponent } from '../../../../../ui-essentials/src/lib/components/buttons/icon-button/icon-button.component'; +import { + faHeart, + faBookmark, + faShare, + faDownload, + faEdit, + faTrash, + faSave, + faSearch, + faPlus, + faMinus, + faHome, + faUser, + faCog, + faClose, + faCheck, + faChevronLeft, + faChevronRight, + faEllipsisV, + faStar +} from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'ui-icon-button-demo', + standalone: true, + imports: [ + CommonModule, + IconButtonComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

Icon Button Component Showcase

+ + +
+

Size Variants

+
+ + + + + + +
+
+ + +
+

Filled Variant

+
+ + + + + + + + +
+
+ + +
+

Tonal Variant

+
+ + + + + + + + +
+
+ + +
+

Outlined Variant

+
+ + + + + + + + +
+
+ + +
+

States

+
+ + + + + + +
+

+ Star button pressed state: {{ isPressed ? 'ON' : 'OFF' }} +

+
+ + +
+

Navigation Examples

+
+ + + + + + + + + + +
+
+ + +
+

Destructive Actions

+
+ + +
+

+ Use outlined variant for destructive actions to reduce accidental clicks +

+
+ + +
+

Interaction Test

+

Total clicks: {{ clickCount }}

+

Last clicked: {{ lastClicked || 'None' }}

+
+
+ `, + styleUrl: './icon-button-demo.component.scss' +}) +export class IconButtonDemoComponent { + // FontAwesome icons + faHeart = faHeart; + faBookmark = faBookmark; + faShare = faShare; + faDownload = faDownload; + faEdit = faEdit; + faTrash = faTrash; + faSave = faSave; + faSearch = faSearch; + faPlus = faPlus; + faMinus = faMinus; + faHome = faHome; + faUser = faUser; + faCog = faCog; + faClose = faClose; + faCheck = faCheck; + faChevronLeft = faChevronLeft; + faChevronRight = faChevronRight; + faEllipsisV = faEllipsisV; + faStar = faStar; + + // Demo state + clickCount = 0; + lastClicked = ''; + isPressed = false; + + handleClick(action: string): void { + this.clickCount++; + this.lastClicked = action; + console.log('Icon button clicked:', action); + } + + togglePressed(): void { + this.isPressed = !this.isPressed; + this.handleClick(`toggle star ${this.isPressed ? 'on' : 'off'}`); + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/snackbar-demo/snackbar-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/snackbar-demo/snackbar-demo.component.scss new file mode 100644 index 0000000..9569f21 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/snackbar-demo/snackbar-demo.component.scss @@ -0,0 +1,90 @@ +@use '../../../../../shared-ui/src/styles/semantic/index' as *; + +.demo-container { + padding: $semantic-spacing-layout-section-md; + max-width: 1200px; + margin: 0 auto; +} + +.demo-section { + margin-bottom: $semantic-spacing-layout-section-lg; + + h3 { + font-family: map-get($semantic-typography-heading-h3, font-family); + font-size: map-get($semantic-typography-heading-h3, font-size); + font-weight: map-get($semantic-typography-heading-h3, font-weight); + line-height: map-get($semantic-typography-heading-h3, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-content-heading; + } +} + +.demo-row { + display: flex; + align-items: center; + gap: $semantic-spacing-component-md; + flex-wrap: wrap; +} + +.demo-button { + padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-button-radius; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + line-height: map-get($semantic-typography-button-medium, line-height); + color: $semantic-color-text-primary; + + &:hover { + background: $semantic-color-surface-secondary; + box-shadow: $semantic-shadow-button-hover; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-color: $semantic-color-primary; + + &:hover { + background: $semantic-color-primary; + opacity: $semantic-opacity-hover; + } + } + + &--warning { + background: $semantic-color-warning; + color: $semantic-color-on-warning; + border-color: $semantic-color-warning; + + &:hover { + background: $semantic-color-warning; + opacity: $semantic-opacity-hover; + } + } + + &--danger { + background: $semantic-color-danger; + color: $semantic-color-on-danger; + border-color: $semantic-color-danger; + + &:hover { + background: $semantic-color-danger; + opacity: $semantic-opacity-hover; + } + } +} + +// Static snackbars for demo purposes +.demo-row ui-snackbar { + margin-bottom: $semantic-spacing-component-sm; +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/snackbar-demo/snackbar-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/snackbar-demo/snackbar-demo.component.ts new file mode 100644 index 0000000..660bb9f --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/snackbar-demo/snackbar-demo.component.ts @@ -0,0 +1,246 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { SnackbarAction, SnackbarComponent } from '../../../../../ui-essentials/src/lib/components/feedback/snackbar/snackbar.component'; + +@Component({ + selector: 'ui-snackbar-demo', + standalone: true, + imports: [CommonModule, SnackbarComponent], + template: ` +
+

Snackbar Demo

+ + +
+

Sizes

+
+ @for (size of sizes; track size) { + + + } +
+
+ + +
+

Variants

+
+ @for (variant of variants; track variant) { + + + } +
+
+ + +
+

With Actions

+
+ + + + + +
+
+ + +
+

Positions

+
+ @for (position of positions; track position) { + + } +
+

Click buttons to see snackbars in different positions

+
+ + +
+

Interactive Examples

+
+ + + + +
+
+ + + @for (snackbar of activeSnackbars; track snackbar.id) { + + + } + + +
+

Demo Status

+

Active snackbars: {{ activeSnackbars.length }}

+

Total shown: {{ totalShown }}

+

Last action: {{ lastAction || 'None' }}

+
+
+ `, + styleUrl: './snackbar-demo.component.scss' +}) +export class SnackbarDemoComponent { + sizes = ['sm', 'md', 'lg'] as const; + variants = ['default', 'primary', 'success', 'warning', 'danger', 'info'] as const; + positions = [ + 'bottom-center', 'bottom-left', 'bottom-right', + 'top-center', 'top-left', 'top-right' + ] as const; + + activeSnackbars: any[] = []; + totalShown = 0; + lastAction = ''; + + singleAction: SnackbarAction[] = [ + { label: 'UNDO', handler: () => this.handleDemoAction('Undo clicked') } + ]; + + multipleActions: SnackbarAction[] = [ + { label: 'RETRY', handler: () => this.handleDemoAction('Retry clicked') }, + { label: 'SETTINGS', handler: () => this.handleDemoAction('Settings clicked') } + ]; + + showPositionDemo(position: string): void { + this.addSnackbar({ + message: `Snackbar at ${position}`, + variant: 'primary', + position: position as any, + showIcon: true, + autoDismiss: true, + duration: 3000 + }); + } + + showSuccessSnackbar(): void { + this.addSnackbar({ + message: 'Operation completed successfully!', + variant: 'success', + showIcon: true, + actions: this.singleAction + }); + } + + showWarningSnackbar(): void { + this.addSnackbar({ + message: 'Warning: This action cannot be undone', + variant: 'warning', + showIcon: true, + autoDismiss: false + }); + } + + showErrorSnackbar(): void { + this.addSnackbar({ + message: 'Error: Failed to save changes', + variant: 'danger', + showIcon: true, + actions: this.multipleActions, + autoDismiss: false + }); + } + + showActionSnackbar(): void { + this.addSnackbar({ + message: 'File moved to trash', + variant: 'default', + actions: [ + { + label: 'UNDO', + handler: () => { + this.handleDemoAction('File restored'); + this.removeSnackbar(this.activeSnackbars[this.activeSnackbars.length - 1]?.id); + } + } + ], + duration: 6000 + }); + } + + private addSnackbar(config: any): void { + const snackbar = { + id: Date.now() + Math.random(), + size: 'md', + position: 'bottom-center', + autoDismiss: true, + duration: 4000, + showIcon: false, + actions: [], + ...config + }; + + this.activeSnackbars.push(snackbar); + this.totalShown++; + } + + removeSnackbar(id: number): void { + this.activeSnackbars = this.activeSnackbars.filter(s => s.id !== id); + } + + handleActionClick(action: SnackbarAction, snackbarId: number): void { + this.lastAction = `${action.label} clicked on snackbar ${snackbarId}`; + } + + private handleDemoAction(action: string): void { + this.lastAction = action; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.scss index 3ac5457..2e25ba9 100644 --- a/projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.scss +++ b/projects/demo-ui-essentials/src/app/demos/split-button-demo/split-button-demo.component.scss @@ -10,10 +10,10 @@ margin-bottom: $semantic-spacing-layout-section-lg; h3 { - font-family: map-get($semantic-typography-heading-h3, font-family); - font-size: map-get($semantic-typography-heading-h3, font-size); - font-weight: map-get($semantic-typography-heading-h3, font-weight); - line-height: map-get($semantic-typography-heading-h3, line-height); + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-lg; + font-weight: $semantic-typography-font-weight-semibold; + line-height: $semantic-typography-line-height-tight; color: $semantic-color-text-primary; margin-bottom: $semantic-spacing-component-md; border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary; @@ -36,10 +36,10 @@ border-radius: $semantic-border-radius-md; p { - font-family: map-get($semantic-typography-body-medium, font-family); - font-size: map-get($semantic-typography-body-medium, font-size); - font-weight: map-get($semantic-typography-body-medium, font-weight); - line-height: map-get($semantic-typography-body-medium, line-height); + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-md; + font-weight: $semantic-typography-font-weight-normal; + line-height: $semantic-typography-line-height-normal; color: $semantic-color-text-primary; margin: $semantic-spacing-component-xs 0; diff --git a/projects/demo-ui-essentials/src/app/demos/stepper-demo/stepper-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/stepper-demo/stepper-demo.component.scss new file mode 100644 index 0000000..943dc3e --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/stepper-demo/stepper-demo.component.scss @@ -0,0 +1,196 @@ +@use '../../../../../shared-ui/src/styles/semantic/index' as *; + +.demo-container { + padding: $semantic-spacing-layout-section-sm; + max-width: 1200px; + margin: 0 auto; +} + +.demo-section { + margin-bottom: $semantic-spacing-layout-section-md; + + h2 { + font-family: map-get($semantic-typography-heading-h2, font-family); + font-size: map-get($semantic-typography-heading-h2, font-size); + font-weight: map-get($semantic-typography-heading-h2, font-weight); + line-height: map-get($semantic-typography-heading-h2, line-height); + color: $semantic-color-text-primary; + margin: 0 0 $semantic-spacing-content-heading 0; + } + + h3 { + font-family: map-get($semantic-typography-heading-h3, font-family); + font-size: map-get($semantic-typography-heading-h3, font-size); + font-weight: map-get($semantic-typography-heading-h3, font-weight); + line-height: map-get($semantic-typography-heading-h3, line-height); + color: $semantic-color-text-primary; + margin: 0 0 $semantic-spacing-component-lg 0; + border-bottom: 1px solid $semantic-color-border-primary; + padding-bottom: $semantic-spacing-component-sm; + } + + h4 { + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + color: $semantic-color-text-secondary; + margin: 0 0 $semantic-spacing-component-md 0; + } +} + +.demo-subsection { + margin-bottom: $semantic-spacing-component-xl; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-lg; + border: 1px solid $semantic-color-border-primary; +} + +.demo-row { + display: flex; + gap: $semantic-spacing-component-xl; + flex-wrap: wrap; + margin-bottom: $semantic-spacing-component-lg; +} + +.demo-item { + flex: 1; + min-width: 300px; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-primary; + border: 1px solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; +} + +.vertical-stepper-container { + max-width: 400px; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-primary; + border: 1px solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; +} + +.interactive-example { + padding: $semantic-spacing-component-xl; + background: $semantic-color-surface-primary; + border: 2px solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-lg; + margin-bottom: $semantic-spacing-component-lg; +} + +.demo-controls { + display: flex; + gap: $semantic-spacing-component-sm; + margin-top: $semantic-spacing-component-lg; + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-secondary; + border-radius: $semantic-border-radius-sm; + flex-wrap: wrap; +} + +.demo-button { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x; + background: $semantic-color-brand-primary; + color: $semantic-color-on-brand-primary; + border: none; + border-radius: $semantic-border-radius-sm; + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background: $semantic-color-brand-secondary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-elevation-2; + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + + &:active { + transform: translateY(0); + box-shadow: $semantic-shadow-elevation-1; + } + + fa-icon { + font-size: $semantic-sizing-icon-inline; + } +} + +.step-info { + margin-top: $semantic-spacing-component-lg; + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-elevated; + border-radius: $semantic-border-radius-sm; + border-left: 4px solid $semantic-color-brand-primary; + + p { + margin: 0 0 $semantic-spacing-content-line-tight 0; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + + &:last-child { + margin-bottom: 0; + } + + strong { + color: $semantic-color-text-primary; + font-weight: $semantic-typography-font-weight-semibold; + } + } +} + +// Responsive design +@media (max-width: 768px) { + .demo-container { + padding: $semantic-spacing-component-md; + } + + .demo-row { + flex-direction: column; + gap: $semantic-spacing-component-md; + } + + .demo-item { + min-width: auto; + } + + .demo-controls { + flex-direction: column; + + .demo-button { + justify-content: center; + } + } + + .vertical-stepper-container { + max-width: none; + } +} + +@media (max-width: 480px) { + .demo-subsection, + .demo-item, + .interactive-example { + padding: $semantic-spacing-component-md; + } + + .demo-section h2 { + font-size: map-get($semantic-typography-heading-h3, font-size); + } + + .demo-section h3 { + font-size: map-get($semantic-typography-heading-h4, font-size); + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/stepper-demo/stepper-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/stepper-demo/stepper-demo.component.ts new file mode 100644 index 0000000..b671400 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/stepper-demo/stepper-demo.component.ts @@ -0,0 +1,362 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faPlay, faUndo } from '@fortawesome/free-solid-svg-icons'; +import { StepperComponent, StepperStep } from '../../../../../ui-essentials/src/lib/components/navigation/stepper'; + +@Component({ + selector: 'ui-stepper-demo', + standalone: true, + imports: [CommonModule, StepperComponent, FontAwesomeModule], + template: ` +
+

Stepper Demo

+ + +
+

Orientation

+
+

Horizontal Stepper

+ + +
+ +
+

Vertical Stepper

+
+ + +
+
+
+ + +
+

Sizes

+
+ @for (size of sizes; track size) { +
+

{{ size }} Size

+ + +
+ } +
+
+ + +
+

Variants

+
+
+

Default

+ + +
+
+

Outlined

+ + +
+
+
+ + +
+

Dense Layout

+
+
+

Normal

+ + +
+
+

Dense

+ + +
+
+
+ + +
+

Step States

+
+

All States

+ + +
+
+ + +
+

Navigation Modes

+
+
+

Linear (Default)

+ + +
+ + +
+
+ +
+

Non-Linear

+ + +
+
+
+ + +
+

Interactive Example

+
+ + + +
+ + + + +
+ +
+

Current Step: {{ getCurrentStepInfo() }}

+

Completed Steps: {{ getCompletedStepsCount() }}

+

Last Action: {{ lastAction }}

+
+
+
+
+ `, + styleUrl: './stepper-demo.component.scss' +}) +export class StepperDemoComponent { + sizes = ['sm', 'md', 'lg'] as const; + playIcon = faPlay; + resetIcon = faUndo; + + lastAction = 'None'; + + // Horizontal stepper data + horizontalSteps: StepperStep[] = [ + { id: 'h1', title: 'Account Info', description: 'Enter your personal details', current: true }, + { id: 'h2', title: 'Verification', description: 'Verify your email address' }, + { id: 'h3', title: 'Preferences', description: 'Set up your preferences' }, + { id: 'h4', title: 'Complete', description: 'Account setup finished' } + ]; + + // Vertical stepper data + verticalSteps: StepperStep[] = [ + { id: 'v1', title: 'Project Setup', description: 'Initialize your new project', completed: true }, + { id: 'v2', title: 'Configuration', description: 'Configure project settings', current: true }, + { id: 'v3', title: 'Dependencies', description: 'Install required dependencies' }, + { id: 'v4', title: 'Testing', description: 'Run initial tests' }, + { id: 'v5', title: 'Deployment', description: 'Deploy to production' } + ]; + + // Size demonstration + sizeSteps: StepperStep[] = [ + { id: 's1', title: 'Step 1', completed: true }, + { id: 's2', title: 'Step 2', current: true }, + { id: 's3', title: 'Step 3' } + ]; + + // Variant demonstration + variantSteps: StepperStep[] = [ + { id: 'var1', title: 'Design', completed: true }, + { id: 'var2', title: 'Development', current: true }, + { id: 'var3', title: 'Testing' }, + { id: 'var4', title: 'Launch' } + ]; + + // Dense demonstration + denseSteps: StepperStep[] = [ + { id: 'd1', title: 'Upload', completed: true }, + { id: 'd2', title: 'Process', current: true }, + { id: 'd3', title: 'Review' }, + { id: 'd4', title: 'Publish' } + ]; + + // State demonstration + stateSteps: StepperStep[] = [ + { id: 'st1', title: 'Completed', description: 'This step is done', completed: true }, + { id: 'st2', title: 'Current', description: 'Currently active step', current: true }, + { id: 'st3', title: 'Error', description: 'This step has an error', error: true }, + { id: 'st4', title: 'Disabled', description: 'This step is disabled', disabled: true }, + { id: 'st5', title: 'Pending', description: 'This step is pending' } + ]; + + // Linear stepper + linearSteps: StepperStep[] = [ + { id: 'l1', title: 'Start', description: 'Begin the process', current: true }, + { id: 'l2', title: 'Middle', description: 'Continue the process' }, + { id: 'l3', title: 'End', description: 'Finish the process' } + ]; + + // Non-linear stepper + nonLinearSteps: StepperStep[] = [ + { id: 'nl1', title: 'Overview', description: 'Click any step', completed: true }, + { id: 'nl2', title: 'Details', description: 'Add more information', current: true }, + { id: 'nl3', title: 'Review', description: 'Check your work' } + ]; + + // Interactive example + interactiveSteps: StepperStep[] = [ + { id: 'i1', title: 'Planning', description: 'Plan your approach', current: true }, + { id: 'i2', title: 'Implementation', description: 'Build the solution' }, + { id: 'i3', title: 'Testing', description: 'Test thoroughly' }, + { id: 'i4', title: 'Deployment', description: 'Go live' } + ]; + + onStepClick(type: string, event: {step: StepperStep, index: number}): void { + this.lastAction = `${type}: Clicked step "${event.step.title}" (index ${event.index})`; + console.log(`${type} stepper - Step clicked:`, event); + } + + onStepChange(type: string, event: {step: StepperStep, index: number}): void { + this.lastAction = `${type}: Changed to step "${event.step.title}" (index ${event.index})`; + console.log(`${type} stepper - Step changed:`, event); + } + + nextLinearStep(): void { + const currentIndex = this.linearSteps.findIndex(step => step.current); + if (currentIndex >= 0 && currentIndex < this.linearSteps.length - 1) { + // Mark current as completed + this.linearSteps[currentIndex].completed = true; + this.linearSteps[currentIndex].current = false; + + // Move to next + this.linearSteps[currentIndex + 1].current = true; + + this.lastAction = `Linear: Advanced to step ${currentIndex + 2}`; + } + } + + resetLinearStepper(): void { + this.linearSteps.forEach((step, index) => { + step.current = index === 0; + step.completed = false; + step.error = false; + }); + this.lastAction = 'Linear: Reset to beginning'; + } + + onInteractiveStepClick(event: {step: StepperStep, index: number}): void { + this.lastAction = `Interactive: Clicked "${event.step.title}"`; + } + + onInteractiveStepChange(event: {step: StepperStep, index: number}): void { + this.lastAction = `Interactive: Changed to "${event.step.title}"`; + } + + markCurrentCompleted(): void { + const currentStep = this.interactiveSteps.find(step => step.current); + if (currentStep) { + currentStep.completed = true; + currentStep.error = false; + this.lastAction = `Interactive: Marked "${currentStep.title}" as completed`; + } + } + + markCurrentError(): void { + const currentStep = this.interactiveSteps.find(step => step.current); + if (currentStep) { + currentStep.error = true; + currentStep.completed = false; + this.lastAction = `Interactive: Marked "${currentStep.title}" as error`; + } + } + + nextInteractiveStep(): void { + const currentIndex = this.interactiveSteps.findIndex(step => step.current); + if (currentIndex >= 0 && currentIndex < this.interactiveSteps.length - 1) { + // Mark current as completed if not in error + if (!this.interactiveSteps[currentIndex].error) { + this.interactiveSteps[currentIndex].completed = true; + } + this.interactiveSteps[currentIndex].current = false; + + // Move to next + this.interactiveSteps[currentIndex + 1].current = true; + + this.lastAction = `Interactive: Advanced to step ${currentIndex + 2}`; + } + } + + resetInteractiveStepper(): void { + this.interactiveSteps.forEach((step, index) => { + step.current = index === 0; + step.completed = false; + step.error = false; + }); + this.lastAction = 'Interactive: Reset to beginning'; + } + + getCurrentStepInfo(): string { + const currentStep = this.interactiveSteps.find(step => step.current); + return currentStep ? `${currentStep.title} (${this.interactiveSteps.indexOf(currentStep) + 1} of ${this.interactiveSteps.length})` : 'None'; + } + + getCompletedStepsCount(): number { + return this.interactiveSteps.filter(step => step.completed).length; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/table-demo/table-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/table-demo/table-demo.component.ts index 326cdbb..5ba1b98 100644 --- a/projects/demo-ui-essentials/src/app/demos/table-demo/table-demo.component.ts +++ b/projects/demo-ui-essentials/src/app/demos/table-demo/table-demo.component.ts @@ -1,7 +1,7 @@ import { Component, ChangeDetectionStrategy, TemplateRef, ViewChild } from '@angular/core'; import { CommonModule } from '@angular/common'; import { TableComponent } from '../../../../../ui-essentials/src/lib/components/data-display/table/table.component'; -import { TableAction, TableActionsComponent } from '../../../../../ui-essentials/src/lib/components/data-display/table-actions.component'; +import { TableAction, TableActionsComponent } from '../../../../../ui-essentials/src/lib/components/data-display/table/table-actions.component'; import type { TableColumn } from '../../../../../ui-essentials/src/lib/components/data-display/table/table.component'; import type { TableSortEvent } from '../../../../../ui-essentials/src/lib/components/data-display/table/table.component'; import { StatusBadgeComponent } from '../../../../../ui-essentials/src/lib/components/feedback/status-badge.component'; diff --git a/projects/demo-ui-essentials/src/app/demos/tag-input-demo/tag-input-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/tag-input-demo/tag-input-demo.component.ts new file mode 100644 index 0000000..b60b126 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/tag-input-demo/tag-input-demo.component.ts @@ -0,0 +1,543 @@ +import { Component, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { TagInputComponent } from '../../../../../ui-essentials/src/lib/components/forms/tag-input/tag-input.component'; +import { ButtonComponent } from '../../../../../ui-essentials/src/lib/components/buttons/button.component'; +import { faTag, faPlus, faTimes, faRefresh } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'ui-tag-input-demo', + standalone: true, + imports: [ + CommonModule, + ReactiveFormsModule, + TagInputComponent, + ButtonComponent + ], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` +
+

Tag Input Component Showcase

+ + +
+

Basic Variants

+ +

Outlined (Default)

+
+ + +
+ +

Filled

+
+ + +
+
+ + +
+

Size Variants

+ +

Small

+
+ + +
+ +

Medium (Default)

+
+ + +
+ +

Large

+
+ + +
+
+ + +
+

Configuration Options

+ +

Max Tags (3)

+
+ + +
+ +

Max Tag Length (10)

+
+ + +
+ +

Allow Duplicates

+
+ + +
+ +

No Trimming

+
+ + +
+
+ + +
+

States

+ +

Normal

+
+ + +
+ +

Disabled

+
+ + +
+ +

Read Only

+
+ + +
+ +

With Error

+
+ + +
+
+ + +
+

Form Integration

+
+

Reactive Form Example

+
+ + +
+ +

Skills (Optional)

+
+ + +
+ +
+ + Submit Form + + + Reset + +
+ + @if (formSubmitted) { +
+ Form Submitted!
+ Tags: {{ formData.tags?.join(', ') || 'None' }}
+ Skills: {{ formData.skills?.join(', ') || 'None' }} +
+ } +
+
+ + +
+

Programmatic Control

+
+ + +
+ +
+ + Add Random Tag + + + Clear All + + + Add Multiple + + + Get as String + +
+
+ + +
+

Usage Examples

+
+

Basic Usage:

+
<ui-tag-input 
+  placeholder="Add tags..."
+  (tagAdd)="onTagAdd($event)"
+  (tagRemove)="onTagRemove($event)">
+</ui-tag-input>
+ +

With Constraints:

+
<ui-tag-input 
+  placeholder="Add up to 5 tags"
+  [maxTags]="5"
+  [maxTagLength]="20"
+  [allowDuplicates]="false"
+  helpText="Each tag can be up to 20 characters"
+  (maxTagsReached)="onMaxTags()"
+  (duplicateTag)="onDuplicate($event)">
+</ui-tag-input>
+ +

Form Integration:

+
<form [formGroup]="myForm">
+  <ui-tag-input 
+    formControlName="tags"
+    placeholder="Required tags"
+    [errorMessage]="getFieldError('tags')">
+  </ui-tag-input>
+</form>
+ +

Custom Separators:

+
<ui-tag-input 
+  placeholder="Use space or dash to separate"
+  [separators]="[' ', '-']"
+  helpText="Tags separated by space or dash">
+</ui-tag-input>
+
+
+ + +
+

Event Log

+ @if (lastAction) { +
+ Last action: {{ lastAction }} +
+ } +
+ Clear Log + Show Alert +
+
+
+ `, + styles: [` + h2 { + color: hsl(279, 14%, 11%); + font-size: 2rem; + margin-bottom: 2rem; + border-bottom: 2px solid hsl(258, 100%, 47%); + padding-bottom: 0.5rem; + } + + h3 { + color: hsl(279, 14%, 25%); + font-size: 1.5rem; + margin-bottom: 1rem; + } + + h4 { + color: hsl(287, 12%, 35%); + font-size: 1.125rem; + margin-bottom: 0.75rem; + } + + section { + border: 1px solid hsl(289, 14%, 90%); + border-radius: 8px; + padding: 1.5rem; + background: hsl(286, 20%, 99%); + } + + .demo-tag-input { + width: 100%; + max-width: 500px; + } + + pre { + font-size: 0.875rem; + line-height: 1.5; + margin: 0.5rem 0; + } + + code { + font-family: 'JetBrains Mono', monospace; + color: #d63384; + } + `] +}) +export class TagInputDemoComponent { + lastAction: string = ''; + + // FontAwesome icons + faTag = faTag; + faPlus = faPlus; + faTimes = faTimes; + faRefresh = faRefresh; + + // Form + demoForm = new FormGroup({ + tags: new FormControl([], [ + Validators.required, + this.minTagsValidator(2) + ]), + skills: new FormControl([]) + }); + + formSubmitted = false; + formData: { tags?: string[]; skills?: string[] } = {}; + + // Predefined tags for programmatic control + predefinedTags = [ + 'JavaScript', 'TypeScript', 'Angular', 'React', 'Vue', + 'HTML', 'CSS', 'SCSS', 'Node.js', 'Python', 'Java', + 'Design', 'Frontend', 'Backend', 'Fullstack' + ]; + + // Event handlers + handleTagAdd(source: string, tag: string): void { + this.lastAction = `Tag added (${source}): "${tag}"`; + console.log(`Tag added (${source}):`, tag); + } + + handleTagRemove(source: string, event: { tag: string; index: number }): void { + this.lastAction = `Tag removed (${source}): "${event.tag}" at index ${event.index}`; + console.log(`Tag removed (${source}):`, event); + } + + handleDuplicateTag(source: string, tag: string): void { + this.lastAction = `Duplicate tag attempted (${source}): "${tag}"`; + console.log(`Duplicate tag attempted (${source}):`, tag); + } + + handleValidationError(source: string, error: { tag: string; error: string }): void { + this.lastAction = `Validation error (${source}): "${error.tag}" - ${error.error}`; + console.log(`Validation error (${source}):`, error); + } + + handleMaxTagsReached(source: string): void { + this.lastAction = `Maximum tags reached (${source})`; + console.log(`Maximum tags reached (${source})`); + } + + // Form methods + minTagsValidator(min: number) { + return (control: any) => { + const tags = control.value; + if (!tags || tags.length < min) { + return { minTags: { required: min, actual: tags?.length || 0 } }; + } + return null; + }; + } + + getFormFieldError(fieldName: string): string | undefined { + const field = this.demoForm.get(fieldName); + if (field && field.invalid && (field.dirty || field.touched)) { + const errors = field.errors; + if (errors?.['required']) { + return 'This field is required'; + } + if (errors?.['minTags']) { + return `At least ${errors['minTags'].required} tags are required`; + } + } + return undefined; + } + + handleFormSubmit(): void { + if (this.demoForm.valid) { + const formValue = this.demoForm.value; + this.formData = { + tags: formValue.tags || [], + skills: formValue.skills || [] + }; + this.formSubmitted = true; + this.lastAction = 'Form submitted successfully'; + console.log('Form submitted:', this.formData); + } else { + this.lastAction = 'Form submission failed - validation errors'; + console.log('Form invalid:', this.demoForm.errors); + } + } + + resetForm(): void { + this.demoForm.reset(); + this.formSubmitted = false; + this.formData = {}; + this.lastAction = 'Form reset'; + console.log('Form reset'); + } + + // Programmatic control methods + addPredefinedTag(tagInput: TagInputComponent): void { + const randomTag = this.predefinedTags[Math.floor(Math.random() * this.predefinedTags.length)]; + const success = tagInput.addTagProgrammatically(randomTag); + this.lastAction = success + ? `Programmatically added: "${randomTag}"` + : `Failed to add: "${randomTag}"`; + console.log('Add predefined tag result:', success, randomTag); + } + + clearProgrammaticInput(tagInput: TagInputComponent): void { + tagInput.clear(); + this.lastAction = 'Programmatically cleared all tags'; + console.log('Cleared programmatic input'); + } + + addMultipleTags(tagInput: TagInputComponent): void { + const tagsToAdd = ['HTML', 'CSS', 'JavaScript']; + let addedCount = 0; + + tagsToAdd.forEach(tag => { + if (tagInput.addTagProgrammatically(tag)) { + addedCount++; + } + }); + + this.lastAction = `Programmatically added ${addedCount}/${tagsToAdd.length} tags`; + console.log(`Added ${addedCount} multiple tags`); + } + + getTagsAsString(tagInput: TagInputComponent): void { + const tagsString = tagInput.getTagsAsString(); + this.lastAction = `Tags as string: "${tagsString}"`; + alert(`Current tags: ${tagsString || 'None'}`); + console.log('Tags as string:', tagsString); + } + + // Utility methods + clearEventLog(): void { + this.lastAction = ''; + } + + showAlert(message: string): void { + alert(message); + this.lastAction = 'Alert shown'; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.scss index 125da0a..12e0290 100644 --- a/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.scss +++ b/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.scss @@ -195,7 +195,7 @@ } // Responsive Design -@media (max-width: $semantic-breakpoint-md - 1) { +@media (max-width: calc($semantic-breakpoint-md - 1px)) { .demo-container { padding: $semantic-spacing-layout-section-sm; } @@ -220,7 +220,7 @@ } } -@media (max-width: $semantic-breakpoint-sm - 1) { +@media (max-width: calc($semantic-breakpoint-sm - 1px)) { .demo-container { padding: $semantic-spacing-layout-section-xs; } diff --git a/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.ts index adacfcb..c098d4e 100644 --- a/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.ts +++ b/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.ts @@ -2,7 +2,7 @@ import { Component } from '@angular/core'; import { CommonModule } from '@angular/common'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { faPlay, faStop, faRedo } from '@fortawesome/free-solid-svg-icons'; -import { ToastComponent } from '../../../../../../dist/ui-essentials/lib/components/feedback/toast/toast.component'; +import { ToastComponent } from '../../../../../ui-essentials/src/lib/components/feedback/toast/toast.component'; @Component({ selector: 'ui-toast-demo', diff --git a/projects/demo-ui-essentials/src/app/demos/transfer-list-demo/transfer-list-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/transfer-list-demo/transfer-list-demo.component.scss new file mode 100644 index 0000000..95828b8 --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/transfer-list-demo/transfer-list-demo.component.scss @@ -0,0 +1,202 @@ +@import "../../../../../shared-ui/src/styles/semantic"; + +.demo-container { + padding: $semantic-spacing-layout-section-md; + max-width: 1200px; + margin: 0 auto; + + h2 { + margin: 0 0 $semantic-spacing-layout-section-lg 0; + font-family: map-get($semantic-typography-heading-h2, font-family); + font-size: map-get($semantic-typography-heading-h2, font-size); + font-weight: map-get($semantic-typography-heading-h2, font-weight); + line-height: map-get($semantic-typography-heading-h2, line-height); + color: $semantic-color-text-primary; + } +} + +.demo-section { + margin-bottom: $semantic-spacing-layout-section-xl; + + h3 { + margin: 0 0 $semantic-spacing-component-md 0; + font-family: map-get($semantic-typography-heading-h3, font-family); + font-size: map-get($semantic-typography-heading-h3, font-size); + font-weight: map-get($semantic-typography-heading-h3, font-weight); + line-height: map-get($semantic-typography-heading-h3, line-height); + color: $semantic-color-text-primary; + } + + h4 { + margin: 0 0 $semantic-spacing-component-sm 0; + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + color: $semantic-color-text-primary; + } +} + +.demo-description { + margin: 0 0 $semantic-spacing-component-lg 0; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-secondary; +} + +.demo-row { + display: flex; + gap: $semantic-spacing-component-lg; + align-items: flex-start; + + &--vertical { + flex-direction: column; + } +} + +.demo-item { + flex: 1; + min-width: 0; +} + +.demo-controls { + display: flex; + flex-wrap: wrap; + gap: $semantic-spacing-component-md; + margin-bottom: $semantic-spacing-component-lg; + padding: $semantic-spacing-component-md; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-radius: $semantic-border-card-radius; + + label { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + cursor: pointer; + + font-family: map-get($semantic-typography-label, font-family); + font-size: map-get($semantic-typography-label, font-size); + font-weight: map-get($semantic-typography-label, font-weight); + line-height: map-get($semantic-typography-label, line-height); + color: $semantic-color-text-primary; + + input[type="checkbox"] { + margin: 0; + } + + &:hover { + color: $semantic-color-primary; + } + } +} + +.demo-event-log { + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-card-radius; + padding: $semantic-spacing-component-md; + max-height: 300px; + overflow-y: auto; +} + +.demo-no-events { + margin: 0; + padding: $semantic-spacing-component-lg 0; + text-align: center; + color: $semantic-color-text-tertiary; + font-style: italic; +} + +.demo-event { + padding: $semantic-spacing-component-xs 0; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-primary; + + &:last-of-type { + border-bottom: none; + } + + strong { + color: $semantic-color-primary; + font-weight: $semantic-typography-font-weight-semibold; + } +} + +.demo-event-time { + display: block; + margin-top: $semantic-spacing-component-xs; + color: $semantic-color-text-tertiary; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); +} + +.demo-clear-events { + margin-top: $semantic-spacing-component-md; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-radius: $semantic-border-button-radius; + + font-family: map-get($semantic-typography-button-small, font-family); + font-size: map-get($semantic-typography-button-small, font-size); + font-weight: map-get($semantic-typography-button-small, font-weight); + line-height: map-get($semantic-typography-button-small, line-height); + color: $semantic-color-text-secondary; + + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-border-primary; + color: $semantic-color-text-primary; + } + + &:focus { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } +} + +// Responsive design +@media (max-width: 768px) { + .demo-container { + padding: $semantic-spacing-layout-section-sm; + } + + .demo-row { + flex-direction: column; + gap: $semantic-spacing-component-md; + } + + .demo-controls { + flex-direction: column; + align-items: flex-start; + gap: $semantic-spacing-component-sm; + } +} + +@media (max-width: 480px) { + .demo-container { + padding: $semantic-spacing-component-md; + } + + .demo-section { + margin-bottom: $semantic-spacing-layout-section-md; + } + + .demo-event-log { + max-height: 200px; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/transfer-list-demo/transfer-list-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/transfer-list-demo/transfer-list-demo.component.ts new file mode 100644 index 0000000..7bc5ccd --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/transfer-list-demo/transfer-list-demo.component.ts @@ -0,0 +1,379 @@ +import { Component, signal } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { TransferListComponent, TransferListItem } from '../../../../../ui-essentials/src/lib/components/data-display/transfer-list/transfer-list.component'; + +@Component({ + selector: 'ui-transfer-list-demo', + standalone: true, + imports: [CommonModule, TransferListComponent], + template: ` +
+

Transfer List Demo

+ + +
+

Basic Transfer List

+

+ Select items from the source list and transfer them to the target list using the control buttons. +

+ +
+ + +
+

Size Variants

+
+
+

Small

+ +
+ +
+

Medium (Default)

+ +
+ +
+

Large

+ +
+
+
+ + +
+

Configuration Options

+ +
+ + + + + +
+ + +
+ + +
+

With Disabled Items

+

+ Some items can be disabled and cannot be selected or transferred. +

+ +
+ + +
+

Large Dataset

+

+ Transfer list with a large number of items to test performance and search functionality. +

+ +
+ + +
+

Custom Labels & Accessibility

+ +
+ + +
+

Event Log

+
+ @if (eventLog().length === 0) { +

No events logged yet. Interact with the transfer lists above to see events.

+ } @else { + @for (event of eventLog(); track $index) { +
+ {{ event.type }}: {{ event.message }} + {{ event.timestamp | date:'short' }} +
+ } + } + @if (eventLog().length > 0) { + + } +
+
+
+ `, + styleUrl: './transfer-list-demo.component.scss' +}) +export class TransferListDemoComponent { + // Basic example data + basicSourceItems = signal([ + { id: 1, label: 'Apple' }, + { id: 2, label: 'Banana' }, + { id: 3, label: 'Cherry' }, + { id: 4, label: 'Date' }, + { id: 5, label: 'Elderberry' }, + { id: 6, label: 'Fig' }, + { id: 7, label: 'Grape' }, + { id: 8, label: 'Honeydew' } + ]); + + basicTargetItems = signal([ + { id: 9, label: 'Orange' }, + { id: 10, label: 'Pear' } + ]); + + // Size variant data + sizeSourceItems = signal([ + { id: 'size1', label: 'Item 1' }, + { id: 'size2', label: 'Item 2' }, + { id: 'size3', label: 'Item 3' } + ]); + + sizeTargetItems = signal([ + { id: 'size4', label: 'Item 4' } + ]); + + // Configuration example data + configSourceItems = signal([ + { id: 'config1', label: 'Configuration Option 1' }, + { id: 'config2', label: 'Configuration Option 2' }, + { id: 'config3', label: 'Configuration Option 3' }, + { id: 'config4', label: 'Configuration Option 4' }, + { id: 'config5', label: 'Configuration Option 5' } + ]); + + configTargetItems = signal([]); + + // Configuration toggles + showSearch = signal(true); + showSelectAll = signal(true); + showCheckboxes = signal(true); + showMoveAll = signal(true); + isDisabled = signal(false); + + // Disabled items example + disabledSourceItems = signal([ + { id: 'dis1', label: 'Regular Item 1' }, + { id: 'dis2', label: 'Disabled Item', disabled: true }, + { id: 'dis3', label: 'Regular Item 2' }, + { id: 'dis4', label: 'Another Disabled Item', disabled: true }, + { id: 'dis5', label: 'Regular Item 3' } + ]); + + disabledTargetItems = signal([]); + + // Large dataset + largeSourceItems = signal(this.generateLargeDataset()); + largeTargetItems = signal([]); + + // Custom labels example + customSourceItems = signal([ + { id: 'perm1', label: 'Read Users', data: { permission: 'users:read' } }, + { id: 'perm2', label: 'Write Users', data: { permission: 'users:write' } }, + { id: 'perm3', label: 'Delete Users', data: { permission: 'users:delete' } }, + { id: 'perm4', label: 'Read Posts', data: { permission: 'posts:read' } }, + { id: 'perm5', label: 'Write Posts', data: { permission: 'posts:write' } }, + { id: 'perm6', label: 'Delete Posts', data: { permission: 'posts:delete' } }, + { id: 'perm7', label: 'Manage Settings', data: { permission: 'settings:manage' } }, + { id: 'perm8', label: 'View Analytics', data: { permission: 'analytics:view' } } + ]); + + customTargetItems = signal([]); + + // Event logging + eventLog = signal>([]); + + // Generate large dataset for performance testing + private generateLargeDataset(): TransferListItem[] { + const items: TransferListItem[] = []; + const categories = ['Documents', 'Images', 'Videos', 'Audio', 'Archives', 'Code']; + const types = ['Personal', 'Work', 'Project', 'Backup', 'Temp']; + + for (let i = 1; i <= 100; i++) { + const category = categories[i % categories.length]; + const type = types[i % types.length]; + items.push({ + id: `large${i}`, + label: `${category} - ${type} File ${i.toString().padStart(3, '0')}`, + data: { category, type, index: i } + }); + } + return items; + } + + // Event handlers for basic example + onBasicSourceChange(items: TransferListItem[]) { + this.basicSourceItems.set(items); + this.logEvent('Source Change', `Basic source now has ${items.length} items`); + } + + onBasicTargetChange(items: TransferListItem[]) { + this.basicTargetItems.set(items); + this.logEvent('Target Change', `Basic target now has ${items.length} items`); + } + + // Event handlers for configuration example + onConfigSourceChange(items: TransferListItem[]) { + this.configSourceItems.set(items); + this.logEvent('Config Source Change', `Config source now has ${items.length} items`); + } + + onConfigTargetChange(items: TransferListItem[]) { + this.configTargetItems.set(items); + this.logEvent('Config Target Change', `Config target now has ${items.length} items`); + } + + // Event handlers for disabled items example + onDisabledSourceChange(items: TransferListItem[]) { + this.disabledSourceItems.set(items); + this.logEvent('Disabled Source Change', `Disabled source now has ${items.length} items`); + } + + onDisabledTargetChange(items: TransferListItem[]) { + this.disabledTargetItems.set(items); + this.logEvent('Disabled Target Change', `Disabled target now has ${items.length} items`); + } + + // Event handlers for large dataset example + onLargeSourceChange(items: TransferListItem[]) { + this.largeSourceItems.set(items); + this.logEvent('Large Source Change', `Large source now has ${items.length} items`); + } + + onLargeTargetChange(items: TransferListItem[]) { + this.largeTargetItems.set(items); + this.logEvent('Large Target Change', `Large target now has ${items.length} items`); + } + + // Event handlers for custom example + onCustomSourceChange(items: TransferListItem[]) { + this.customSourceItems.set(items); + this.logEvent('Custom Source Change', `Custom source now has ${items.length} items`); + } + + onCustomTargetChange(items: TransferListItem[]) { + this.customTargetItems.set(items); + this.logEvent('Custom Target Change', `Custom target now has ${items.length} items`); + } + + // Item move event handler + onItemMove(event: { item: TransferListItem; from: 'source' | 'target'; to: 'source' | 'target' }) { + this.logEvent('Item Move', `Moved "${event.item.label}" from ${event.from} to ${event.to}`); + } + + // Configuration toggle methods + toggleSearch() { + this.showSearch.update(value => !value); + this.logEvent('Config Change', `Search ${this.showSearch() ? 'enabled' : 'disabled'}`); + } + + toggleSelectAll() { + this.showSelectAll.update(value => !value); + this.logEvent('Config Change', `Select All ${this.showSelectAll() ? 'enabled' : 'disabled'}`); + } + + toggleCheckboxes() { + this.showCheckboxes.update(value => !value); + this.logEvent('Config Change', `Checkboxes ${this.showCheckboxes() ? 'enabled' : 'disabled'}`); + } + + toggleMoveAll() { + this.showMoveAll.update(value => !value); + this.logEvent('Config Change', `Move All buttons ${this.showMoveAll() ? 'enabled' : 'disabled'}`); + } + + toggleDisabled() { + this.isDisabled.update(value => !value); + this.logEvent('Config Change', `Component ${this.isDisabled() ? 'disabled' : 'enabled'}`); + } + + // Event logging + private logEvent(type: string, message: string) { + this.eventLog.update(log => [ + { type, message, timestamp: new Date() }, + ...log.slice(0, 9) // Keep only the last 10 events + ]); + } + + clearEventLog() { + this.eventLog.set([]); + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts b/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts index 0a48321..035e616 100644 --- a/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts +++ b/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts @@ -10,7 +10,7 @@ import { faGripVertical, faArrowsAlt, faBoxOpen, faChevronLeft, faSpinner, faExclamationTriangle, faCloudUploadAlt, faFileText, faListAlt, faCircle, faExpandArrowsAlt, faCircleNotch, faSliders, faMinus, faInfoCircle, faChevronDown, faCaretUp, faExclamationCircle, faSitemap, faStream, - faBell, faRoute, faChevronUp, faEllipsisV, faCut, faPalette, faExchangeAlt, faTools + faBell, faRoute, faChevronUp, faEllipsisV, faCut, faPalette, faExchangeAlt, faTools, faHeart } from '@fortawesome/free-solid-svg-icons'; import { DemoRoutes } from '../../demos'; import { ScrollContainerComponent } from '../../../../../ui-essentials/src/lib/layouts/scroll-container.component'; @@ -107,6 +107,7 @@ export class DashboardComponent { faPalette = faPalette; faExchangeAlt = faExchangeAlt; faTools = faTools; + faHeart = faHeart; menuItems: any = [] @@ -153,7 +154,8 @@ export class DashboardComponent { this.createChildItem("file-upload", "File Upload", this.faCloudUploadAlt), this.createChildItem("form-field", "Form Field", this.faFileText), this.createChildItem("range-slider", "Range Slider", this.faSliders), - this.createChildItem("color-picker", "Color Picker", this.faPalette) + this.createChildItem("color-picker", "Color Picker", this.faPalette), + this.createChildItem("tag-input", "Tag Input", this.faTags) ]; this.addMenuItem("forms", "Forms", this.faEdit, formsChildren); @@ -196,6 +198,7 @@ export class DashboardComponent { // Actions category const actionsChildren = [ this.createChildItem("buttons", "Buttons", this.faMousePointer), + this.createChildItem("icon-button", "Icon Buttons", this.faHeart), this.createChildItem("fab-menu", "FAB Menu", this.faEllipsisV), this.createChildItem("split-button", "Split Button", this.faCut) ]; diff --git a/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.sidebar.component.ts b/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.sidebar.component.ts index 7b0f0f5..f7535e5 100644 --- a/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.sidebar.component.ts +++ b/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.sidebar.component.ts @@ -1,14 +1,13 @@ import { Component, EventEmitter, Output, Input, OnInit } from '@angular/core'; import { CommonModule } from '@angular/common'; -import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; -import { MenuItemComponent } from '../../../../../ui-essentials/src/lib/components/navigation/menu/menu-item.component'; -import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons'; +import { + MenuContainerComponent, + MenuSubmenuComponent, + MenuItemComponent, + MenuItemData +} from '../../../../../ui-essentials/src/lib/components/navigation/menu'; -export interface SidebarMenuItem { - id: string; - label: string; - icon?: any; // FontAwesome icon definition - active: boolean; +export interface SidebarMenuItem extends MenuItemData { children?: SidebarMenuItem[]; expanded?: boolean; isParent?: boolean; @@ -19,48 +18,49 @@ export interface SidebarMenuItem { standalone: true, imports: [ CommonModule, - FontAwesomeModule, + MenuContainerComponent, + MenuSubmenuComponent, MenuItemComponent ], template: ` - + `, styleUrls: ['./dashboard.sidebar.component.scss'] @@ -71,9 +71,6 @@ export class DashboardSidebarComponent implements OnInit { @Input() set active(value: string) { this.setActive(value) } @Output() selected = new EventEmitter(); - faChevronRight = faChevronRight; - faChevronDown = faChevronDown; - handleMenuClick(id: string): void { this.setActive(id); this.selected.emit(id); @@ -126,6 +123,35 @@ export class DashboardSidebarComponent implements OnInit { } } + getMenuItemData(item: SidebarMenuItem): MenuItemData { + return { + id: item.id, + label: item.label, + icon: item.icon, + active: item.active, + hasSubmenu: item.isParent && !!item.children, + disabled: item.disabled + }; + } + + onSubmenuToggled(event: { item: MenuItemData; isOpen: boolean }): void { + const sidebarItem = this.findItemById(this.data, event.item.id || ''); + if (sidebarItem && sidebarItem.isParent) { + sidebarItem.expanded = event.isOpen; + } + } + + private findItemById(items: SidebarMenuItem[], id: string): SidebarMenuItem | undefined { + for (const item of items) { + if (item.id === id) return item; + if (item.children) { + const found = this.findItemById(item.children, id); + if (found) return found; + } + } + return undefined; + } + ngOnInit(): void { } diff --git a/projects/ui-essentials/src/lib/components/buttons/fab-menu/fab-menu.component.scss b/projects/ui-essentials/src/lib/components/buttons/fab-menu/fab-menu.component.scss new file mode 100644 index 0000000..25258c2 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/buttons/fab-menu/fab-menu.component.scss @@ -0,0 +1,475 @@ +@use "../../../../../../shared-ui/src/styles/semantic/index" as *; + +.ui-fab-menu { + position: relative; + display: inline-flex; + z-index: $semantic-z-index-dropdown; + + // Position variants + &--bottom-right { + position: fixed; + bottom: $semantic-spacing-layout-section-md; + right: $semantic-spacing-layout-section-md; + } + + &--bottom-left { + position: fixed; + bottom: $semantic-spacing-layout-section-md; + left: $semantic-spacing-layout-section-md; + } + + &--top-right { + position: fixed; + top: $semantic-spacing-layout-section-md; + right: $semantic-spacing-layout-section-md; + } + + &--top-left { + position: fixed; + top: $semantic-spacing-layout-section-md; + left: $semantic-spacing-layout-section-md; + } + + // Size variants + &--sm { + .ui-fab-menu__trigger { + width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + + .ui-fab-menu__trigger-icon { + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + font-size: map-get($semantic-typography-body-small, font-size); + } + } + + .ui-fab-menu__item { + min-height: $semantic-sizing-button-height-sm; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + } + + &--md { + .ui-fab-menu__trigger { + width: $semantic-sizing-button-height-md; + height: $semantic-sizing-button-height-md; + + .ui-fab-menu__trigger-icon { + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + font-size: map-get($semantic-typography-body-medium, font-size); + } + } + + .ui-fab-menu__item { + min-height: $semantic-sizing-button-height-md; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + } + } + + &--lg { + .ui-fab-menu__trigger { + width: $semantic-sizing-button-height-lg; + height: $semantic-sizing-button-height-lg; + + .ui-fab-menu__trigger-icon { + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + font-size: map-get($semantic-typography-body-large, font-size); + } + } + + .ui-fab-menu__item { + min-height: $semantic-sizing-button-height-lg; + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + } + + // Direction variants + &--up { + .ui-fab-menu__items { + flex-direction: column-reverse; + bottom: 100%; + margin-bottom: $semantic-spacing-component-sm; + } + + .ui-fab-menu__item { + transform-origin: center bottom; + } + } + + &--down { + .ui-fab-menu__items { + flex-direction: column; + top: 100%; + margin-top: $semantic-spacing-component-sm; + } + + .ui-fab-menu__item { + transform-origin: center top; + } + } + + &--left { + .ui-fab-menu__items { + flex-direction: row-reverse; + right: 100%; + margin-right: $semantic-spacing-component-sm; + align-items: center; + } + + .ui-fab-menu__item { + transform-origin: right center; + } + } + + &--right { + .ui-fab-menu__items { + flex-direction: row; + left: 100%; + margin-left: $semantic-spacing-component-sm; + align-items: center; + } + + .ui-fab-menu__item { + transform-origin: left center; + } + } + + // State variants + &--disabled { + .ui-fab-menu__trigger { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + } + + &--animating { + pointer-events: none; + } +} + +.ui-fab-menu__trigger { + position: relative; + display: flex; + align-items: center; + justify-content: center; + border: $semantic-border-width-1 solid transparent; + border-radius: $semantic-border-radius-full; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + box-shadow: $semantic-shadow-elevation-2; + overflow: hidden; + + // Color variants + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-color: $semantic-color-primary; + + &:hover:not([disabled]) { + box-shadow: $semantic-shadow-elevation-3; + } + } + + &--secondary { + background: $semantic-color-secondary; + color: $semantic-color-on-secondary; + border-color: $semantic-color-secondary; + + &:hover:not([disabled]) { + box-shadow: $semantic-shadow-elevation-3; + } + } + + &--success { + background: $semantic-color-success; + color: $semantic-color-on-success; + border-color: $semantic-color-success; + + &:hover:not([disabled]) { + box-shadow: $semantic-shadow-elevation-3; + } + } + + &--danger { + background: $semantic-color-danger; + color: $semantic-color-on-danger; + border-color: $semantic-color-danger; + + &:hover:not([disabled]) { + box-shadow: $semantic-shadow-elevation-3; + } + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active:not([disabled]) { + transform: scale(0.95); + box-shadow: $semantic-shadow-elevation-1; + } + + &--active { + transform: rotate(45deg); + } + + .ui-fab-menu__trigger-icon { + display: flex; + align-items: center; + justify-content: center; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &--close { + transform: rotate(-45deg); + } + } + + .ui-fab-menu__trigger-ripple { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: inherit; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: currentColor; + transform: translate(-50%, -50%); + opacity: 0; + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + .ui-fab-menu__trigger:active & { + &::after { + width: 100%; + height: 100%; + opacity: $semantic-opacity-subtle; + } + } + } +} + +.ui-fab-menu__items { + position: absolute; + display: flex; + gap: $semantic-spacing-component-xs; + pointer-events: none; + z-index: 1; + + .ui-fab-menu--open & { + pointer-events: auto; + } +} + +.ui-fab-menu__item { + position: relative; + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-md; + background: $semantic-color-surface-primary; + color: $semantic-color-text-primary; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + box-shadow: $semantic-shadow-elevation-1; + transform: scale(0); + opacity: 0; + overflow: hidden; + white-space: nowrap; + + .ui-fab-menu--open & { + transform: scale(1); + opacity: 1; + + @for $i from 1 through 10 { + &:nth-child(#{$i}) { + transition-delay: #{($i - 1) * 50ms}; + } + } + } + + .ui-fab-menu--animating:not(.ui-fab-menu--open) & { + transition-delay: 0ms; + } + + // Color variants + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-color: $semantic-color-primary; + } + + &--secondary { + background: $semantic-color-secondary; + color: $semantic-color-on-secondary; + border-color: $semantic-color-secondary; + } + + &--success { + background: $semantic-color-success; + color: $semantic-color-on-success; + border-color: $semantic-color-success; + } + + &--danger { + background: $semantic-color-danger; + color: $semantic-color-on-danger; + border-color: $semantic-color-danger; + } + + &:hover:not([disabled]) { + box-shadow: $semantic-shadow-elevation-2; + transform: scale(1.05); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active:not([disabled]) { + transform: scale(0.98); + box-shadow: $semantic-shadow-elevation-1; + } + + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + .ui-fab-menu__item-icon { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + flex-shrink: 0; + } + + .ui-fab-menu__item-label { + font-weight: $semantic-typography-font-weight-medium; + } + + .ui-fab-menu__item-ripple { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + border-radius: inherit; + overflow: hidden; + + &::after { + content: ''; + position: absolute; + top: 50%; + left: 50%; + width: 0; + height: 0; + border-radius: 50%; + background: currentColor; + transform: translate(-50%, -50%); + opacity: 0; + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + .ui-fab-menu__item:active & { + &::after { + width: 100%; + height: 100%; + opacity: $semantic-opacity-subtle; + } + } + } +} + +.ui-fab-menu__backdrop { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: $semantic-color-backdrop; + backdrop-filter: blur(2px); + z-index: -1; + opacity: 0; + transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease; + + .ui-fab-menu--open & { + opacity: 1; + } +} + +// Responsive design +@media (max-width: 768px) { + .ui-fab-menu { + &--bottom-right, + &--bottom-left, + &--top-right, + &--top-left { + margin: $semantic-spacing-component-sm; + } + } + + .ui-fab-menu__item { + .ui-fab-menu__item-label { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + } +} + +// High contrast mode support +@media (prefers-contrast: high) { + .ui-fab-menu__trigger, + .ui-fab-menu__item { + border-width: $semantic-border-width-2; + } +} + +// Reduced motion support +@media (prefers-reduced-motion: reduce) { + .ui-fab-menu__trigger, + .ui-fab-menu__item, + .ui-fab-menu__backdrop, + .ui-fab-menu__trigger-icon, + .ui-fab-menu__trigger-ripple::after, + .ui-fab-menu__item-ripple::after { + transition-duration: 0ms; + animation-duration: 0ms; + } + + .ui-fab-menu__item { + .ui-fab-menu--open & { + transition-delay: 0ms; + } + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/buttons/fab-menu/fab-menu.component.ts b/projects/ui-essentials/src/lib/components/buttons/fab-menu/fab-menu.component.ts new file mode 100644 index 0000000..818be75 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/buttons/fab-menu/fab-menu.component.ts @@ -0,0 +1,266 @@ +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'; + } + } + + @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(); + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/buttons/fab-menu/index.ts b/projects/ui-essentials/src/lib/components/buttons/fab-menu/index.ts new file mode 100644 index 0000000..9c6f57d --- /dev/null +++ b/projects/ui-essentials/src/lib/components/buttons/fab-menu/index.ts @@ -0,0 +1 @@ +export * from './fab-menu.component'; \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/buttons/icon-button/icon-button.component.scss b/projects/ui-essentials/src/lib/components/buttons/icon-button/icon-button.component.scss new file mode 100644 index 0000000..49b3f45 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/buttons/icon-button/icon-button.component.scss @@ -0,0 +1,203 @@ +@use "../../../../../../shared-ui/src/styles/semantic/index" as *; + +.ui-icon-button { + // Reset and base styles + display: inline-flex; + align-items: center; + justify-content: center; + border: none; + border-radius: $semantic-border-radius-md; + cursor: pointer; + text-decoration: none; + outline: none; + position: relative; + overflow: hidden; + + // Make it square/circular + aspect-ratio: 1; + flex-shrink: 0; + + // Interaction states + user-select: none; + -webkit-tap-highlight-color: transparent; + box-sizing: border-box; + + // Transitions + transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + + // Size variants - square buttons with proper touch targets + &--small { + width: $semantic-spacing-component-padding-xl; // 1.5rem + height: $semantic-spacing-component-padding-xl; + border-radius: $semantic-border-radius-sm; + + .icon-button-icon { + font-size: $semantic-spacing-component-md; // 0.75rem + } + } + + &--medium { + width: $semantic-spacing-interactive-touch-target; // 2.75rem + height: $semantic-spacing-interactive-touch-target; + border-radius: $semantic-border-radius-md; + + .icon-button-icon { + font-size: $semantic-spacing-component-lg; // 1rem + } + } + + &--large { + width: $semantic-spacing-layout-section-xs; // 2rem but we need larger + height: $semantic-spacing-layout-section-xs; + border-radius: $semantic-border-radius-lg; + + .icon-button-icon { + font-size: $semantic-spacing-component-xl; // 1.5rem + } + } + + // Filled variant (primary) + &--filled { + background-color: $semantic-color-brand-primary; + color: $semantic-color-on-brand-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-brand-primary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-elevation-2; + filter: brightness(1.1); + } + + &:active:not(:disabled) { + transform: scale(0.95); + box-shadow: $semantic-shadow-elevation-1; + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + } + + // Tonal variant + &--tonal { + background-color: $semantic-color-surface-interactive; + color: $semantic-color-text-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-interactive; + transform: translateY(-1px); + box-shadow: $semantic-shadow-elevation-2; + filter: brightness(0.95); + } + + &:active:not(:disabled) { + transform: scale(0.95); + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + } + + // Outlined variant + &--outlined { + background-color: transparent; + color: $semantic-color-brand-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + + &:hover:not(:disabled) { + background-color: $semantic-color-surface-interactive; + border-color: $semantic-color-brand-primary; + transform: translateY(-1px); + } + + &:active:not(:disabled) { + background-color: $semantic-color-surface-interactive; + transform: scale(0.95); + filter: brightness(0.9); + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + } + + // States + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + &--loading { + cursor: wait; + + .icon-button-icon { + opacity: 0; + } + } + + &--pressed { + background-color: $semantic-color-surface-selected; + color: $semantic-color-text-primary; + + &.ui-icon-button--outlined { + border-color: $semantic-color-brand-primary; + } + } + + // Icon styles + .icon-button-icon { + display: inline-flex; + align-items: center; + justify-content: center; + transition: opacity 200ms ease; + } + + // Loader styles + .icon-button-loader { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + } + + .icon-button-spinner { + width: $semantic-spacing-component-lg; // 1rem + height: $semantic-spacing-component-lg; + border: 2px solid currentColor; + border-top: 2px solid transparent; + border-radius: 50%; + animation: spin 1s linear infinite; + opacity: 0.7; + } + + @keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } + } + + // Ripple effect + &::after { + content: ''; + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%); + transform: scale(0); + opacity: 0; + pointer-events: none; + transition: transform 0.3s ease, opacity 0.3s ease; + } + + &:active:not(:disabled)::after { + transform: scale(1); + opacity: 1; + transition: none; + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/buttons/icon-button/icon-button.component.ts b/projects/ui-essentials/src/lib/components/buttons/icon-button/icon-button.component.ts new file mode 100644 index 0000000..5bd0832 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/buttons/icon-button/icon-button.component.ts @@ -0,0 +1,66 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +export type IconButtonVariant = 'filled' | 'tonal' | 'outlined'; +export type IconButtonSize = 'small' | 'medium' | 'large'; + +@Component({ + selector: 'ui-icon-button', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + + `, + styleUrl: './icon-button.component.scss' +}) +export class IconButtonComponent { + @Input({ required: true }) icon!: IconDefinition; + @Input() variant: IconButtonVariant = 'filled'; + @Input() size: IconButtonSize = 'medium'; + @Input() disabled: boolean = false; + @Input() loading: boolean = false; + @Input() pressed: boolean = false; + @Input() type: 'button' | 'submit' | 'reset' = 'button'; + @Input() class: string = ''; + @Input() ariaLabel: string = ''; + @Input() title: string = ''; + + @Output() clicked = new EventEmitter(); + + get buttonClasses(): string { + return [ + 'ui-icon-button', + `ui-icon-button--${this.variant}`, + `ui-icon-button--${this.size}`, + this.disabled ? 'ui-icon-button--disabled' : '', + this.loading ? 'ui-icon-button--loading' : '', + this.pressed ? 'ui-icon-button--pressed' : '', + this.class + ].filter(Boolean).join(' '); + } + + handleClick(event: Event): void { + if (!this.disabled && !this.loading) { + this.clicked.emit(event); + } + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/buttons/icon-button/index.ts b/projects/ui-essentials/src/lib/components/buttons/icon-button/index.ts new file mode 100644 index 0000000..2e70bf8 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/buttons/icon-button/index.ts @@ -0,0 +1 @@ +export * from './icon-button.component'; \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/buttons/index.ts b/projects/ui-essentials/src/lib/components/buttons/index.ts index 4a83a8a..7f8195c 100644 --- a/projects/ui-essentials/src/lib/components/buttons/index.ts +++ b/projects/ui-essentials/src/lib/components/buttons/index.ts @@ -5,3 +5,4 @@ export * from './fab.component'; export * from './fab-menu'; export * from './simple-button.component'; export * from './split-button'; +export * from './icon-button'; diff --git a/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.scss b/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.scss index 2533134..f177494 100644 --- a/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.scss +++ b/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.scss @@ -11,20 +11,32 @@ // Size variants &--small { height: $semantic-spacing-9; // 2.25rem - font-size: $semantic-typography-font-size-sm; - line-height: $semantic-typography-line-height-tight; + + .ui-split-button__primary, + .ui-split-button__dropdown { + font-size: $semantic-typography-font-size-sm; + line-height: $semantic-typography-line-height-tight; + } } &--medium { height: $semantic-spacing-11; // 2.75rem - font-size: $semantic-typography-font-size-md; - line-height: $semantic-typography-line-height-normal; + + .ui-split-button__primary, + .ui-split-button__dropdown { + font-size: $semantic-typography-font-size-md; + line-height: $semantic-typography-line-height-normal; + } } &--large { height: $semantic-spacing-12; // 3rem - font-size: $semantic-typography-font-size-lg; - line-height: $semantic-typography-line-height-normal; + + .ui-split-button__primary, + .ui-split-button__dropdown { + font-size: $semantic-typography-font-size-lg; + line-height: $semantic-typography-line-height-normal; + } } // Full width variant @@ -49,10 +61,17 @@ text-decoration: none; outline: none; flex: 1; + height: 100%; // Match the container height + + // Default colors (filled variant) + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-primary; // Typography font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-md; // Default medium size font-weight: $semantic-typography-font-weight-medium; + line-height: $semantic-typography-line-height-normal; // Default medium line-height text-align: center; text-transform: none; letter-spacing: $semantic-typography-letter-spacing-normal; @@ -66,6 +85,23 @@ // Transitions transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + // Default hover state + &:hover:not(:disabled) { + background-color: $semantic-color-primary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-button-hover; + } + + &:active:not(:disabled) { + transform: scale(0.98); + box-shadow: $semantic-shadow-button-active; + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + // Size-specific padding .ui-split-button--small & { padding: 0 $semantic-spacing-3; // 0.75rem @@ -90,21 +126,19 @@ outline: none; position: relative; - // Size-specific dimensions - .ui-split-button--small & { - width: $semantic-spacing-9; // 2.25rem - padding: 0 $semantic-spacing-2; // 0.5rem - } + // Default colors (filled variant) + background-color: $semantic-color-interactive-primary; + color: $semantic-color-on-primary; - .ui-split-button--medium & { - width: $semantic-spacing-11; // 2.75rem - padding: 0 $semantic-spacing-3; // 0.75rem - } + // Typography + font-family: $semantic-typography-font-family-sans; + font-size: $semantic-typography-font-size-md; // Default medium size + font-weight: $semantic-typography-font-weight-medium; + line-height: $semantic-typography-line-height-normal; // Default medium line-height - .ui-split-button--large & { - width: $semantic-spacing-12; // 3rem - padding: 0 $semantic-spacing-4; // 1rem - } + // Default size (medium) - defaults only + width: $semantic-spacing-9; // Default to medium size + padding: 0 $semantic-spacing-2-5; // Default medium padding // Interaction states user-select: none; @@ -114,6 +148,23 @@ // Transitions transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); + // Default hover state + &:hover:not(:disabled) { + background-color: $semantic-color-primary; + transform: translateY(-1px); + box-shadow: $semantic-shadow-button-hover; + } + + &:active:not(:disabled) { + transform: scale(0.98); + box-shadow: $semantic-shadow-button-active; + } + + &:focus-visible { + outline: 2px solid $semantic-color-primary; + outline-offset: 2px; + } + // Divider between buttons &::before { content: ''; @@ -127,6 +178,25 @@ } } + // Size-specific dropdown dimensions (must come after default styles) + &--small &__dropdown { + width: $semantic-spacing-8; // 2rem - slightly smaller than height + height: $semantic-spacing-9; // Match small container height + padding: 0 $semantic-spacing-2; // 0.5rem + } + + &--medium &__dropdown { + width: $semantic-spacing-9; // 2.25rem - smaller than height + height: $semantic-spacing-11; // Match medium container height + padding: 0 $semantic-spacing-2-5; // 0.625rem + } + + &--large &__dropdown { + width: $semantic-spacing-10; // 2.5rem - smaller than height + height: $semantic-spacing-12; // Match large container height + padding: 0 $semantic-spacing-3; // 0.75rem + } + // Content wrapper &__content { display: flex; @@ -182,7 +252,7 @@ &__arrow { display: inline-flex; align-items: center; - font-size: 0.75em; + font-size: 0.875em; // Slightly larger for better visibility transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1); } diff --git a/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.ts b/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.ts index 616e5a9..787ced9 100644 --- a/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.ts +++ b/projects/ui-essentials/src/lib/components/buttons/split-button/split-button.component.ts @@ -24,11 +24,7 @@ export interface SplitButtonMenuItem { template: `
+ [class]="'ui-split-button--' + size + ' ui-split-button--' + variant + (disabled ? ' ui-split-button--disabled' : '') + (loading ? ' ui-split-button--loading' : '') + (fullWidth ? ' ui-split-button--full-width' : '')"> + } +
+ } + @if (showSelectAll) { + + } + + +
+
+ @if (filteredSourceItems().length === 0) { +
+ @if (sourceSearchTerm()) { + No items match your search + } @else { + No items available + } +
+ } @else { + @for (item of filteredSourceItems(); track item.id) { +
+ @if (showCheckboxes) { + + } + {{ item.label }} +
+ } + } +
+
+ + + + + +
+ + + @if (showMoveAll) { + + } + + @if (showMoveAll) { + + } + + +
+ + +
+
+

{{ targetTitle }}

+ @if (showSearch) { + + } + @if (showSelectAll) { + + } +
+ +
+
+ @if (filteredTargetItems().length === 0) { +
+ @if (targetSearchTerm()) { + No items match your search + } @else { + No items selected + } +
+ } @else { + @for (item of filteredTargetItems(); track item.id) { +
+ @if (showCheckboxes) { + + } + {{ item.label }} +
+ } + } +
+
+ + +
+ + `, + styleUrl: './transfer-list.component.scss' +}) +export class TransferListComponent { + // FontAwesome icons + faChevronRight = faChevronRight; + faChevronLeft = faChevronLeft; + faAnglesRight = faAnglesRight; + faAnglesLeft = faAnglesLeft; + faSearch = faSearch; + faTimes = faTimes; + + // Component inputs + @Input() sourceItems: TransferListItem[] = []; + @Input() targetItems: TransferListItem[] = []; + @Input() sourceTitle = 'Available'; + @Input() targetTitle = 'Selected'; + @Input() size: TransferListSize = 'md'; + @Input() disabled = false; + @Input() showSearch = true; + @Input() showSelectAll = true; + @Input() showCheckboxes = true; + @Input() showMoveAll = true; + @Input() searchPlaceholder = 'Search items...'; + @Input() ariaLabel?: string; + + // Component outputs + @Output() sourceChange = new EventEmitter(); + @Output() targetChange = new EventEmitter(); + @Output() itemMove = new EventEmitter<{ + item: TransferListItem; + from: 'source' | 'target'; + to: 'source' | 'target'; + }>(); + + // Internal signals for reactive state management + private sourceItemsSignal = signal([]); + private targetItemsSignal = signal([]); + private selectedSourceIds = signal>(new Set()); + private selectedTargetIds = signal>(new Set()); + + // Search terms + sourceSearchTerm = signal(''); + targetSearchTerm = signal(''); + + // Computed filtered items + filteredSourceItems = computed(() => { + const items = this.sourceItemsSignal(); + const searchTerm = this.sourceSearchTerm().toLowerCase().trim(); + + if (!searchTerm) return items; + + return items.filter(item => + item.label.toLowerCase().includes(searchTerm) + ); + }); + + filteredTargetItems = computed(() => { + const items = this.targetItemsSignal(); + const searchTerm = this.targetSearchTerm().toLowerCase().trim(); + + if (!searchTerm) return items; + + return items.filter(item => + item.label.toLowerCase().includes(searchTerm) + ); + }); + + // Computed selected items + selectedSourceItems = computed(() => { + const selectedIds = this.selectedSourceIds(); + return this.sourceItemsSignal().filter(item => selectedIds.has(item.id)); + }); + + selectedTargetItems = computed(() => { + const selectedIds = this.selectedTargetIds(); + return this.targetItemsSignal().filter(item => selectedIds.has(item.id)); + }); + + ngOnInit() { + this.sourceItemsSignal.set([...this.sourceItems]); + this.targetItemsSignal.set([...this.targetItems]); + } + + ngOnChanges() { + this.sourceItemsSignal.set([...this.sourceItems]); + this.targetItemsSignal.set([...this.targetItems]); + } + + // Search methods + updateSourceSearch() { + // Clear selection when searching to avoid confusion + this.selectedSourceIds.set(new Set()); + } + + updateTargetSearch() { + // Clear selection when searching to avoid confusion + this.selectedTargetIds.set(new Set()); + } + + clearSourceSearch() { + this.sourceSearchTerm.set(''); + this.selectedSourceIds.set(new Set()); + } + + clearTargetSearch() { + this.targetSearchTerm.set(''); + this.selectedTargetIds.set(new Set()); + } + + // Selection methods + isSourceItemSelected(id: string | number): boolean { + return this.selectedSourceIds().has(id); + } + + isTargetItemSelected(id: string | number): boolean { + return this.selectedTargetIds().has(id); + } + + toggleSourceItem(item: TransferListItem) { + if (this.disabled || item.disabled) return; + + const currentSelected = new Set(this.selectedSourceIds()); + if (currentSelected.has(item.id)) { + currentSelected.delete(item.id); + } else { + currentSelected.add(item.id); + } + this.selectedSourceIds.set(currentSelected); + } + + toggleTargetItem(item: TransferListItem) { + if (this.disabled || item.disabled) return; + + const currentSelected = new Set(this.selectedTargetIds()); + if (currentSelected.has(item.id)) { + currentSelected.delete(item.id); + } else { + currentSelected.add(item.id); + } + this.selectedTargetIds.set(currentSelected); + } + + // Select all methods + getSourceSelectedCount(): number { + return this.selectedSourceIds().size; + } + + getTargetSelectedCount(): number { + return this.selectedTargetIds().size; + } + + isAllSourceSelected(): boolean { + const availableItems = this.filteredSourceItems().filter(item => !item.disabled); + return availableItems.length > 0 && + availableItems.every(item => this.selectedSourceIds().has(item.id)); + } + + isAllTargetSelected(): boolean { + const availableItems = this.filteredTargetItems().filter(item => !item.disabled); + return availableItems.length > 0 && + availableItems.every(item => this.selectedTargetIds().has(item.id)); + } + + isSourceIndeterminate(): boolean { + const availableItems = this.filteredSourceItems().filter(item => !item.disabled); + const selectedCount = availableItems.filter(item => this.selectedSourceIds().has(item.id)).length; + return selectedCount > 0 && selectedCount < availableItems.length; + } + + isTargetIndeterminate(): boolean { + const availableItems = this.filteredTargetItems().filter(item => !item.disabled); + const selectedCount = availableItems.filter(item => this.selectedTargetIds().has(item.id)).length; + return selectedCount > 0 && selectedCount < availableItems.length; + } + + toggleAllSource(event: Event) { + if (this.disabled) return; + + const target = event.target as HTMLInputElement; + const availableItems = this.filteredSourceItems().filter(item => !item.disabled); + + if (target.checked) { + const newSelected = new Set(this.selectedSourceIds()); + availableItems.forEach(item => newSelected.add(item.id)); + this.selectedSourceIds.set(newSelected); + } else { + const newSelected = new Set(this.selectedSourceIds()); + availableItems.forEach(item => newSelected.delete(item.id)); + this.selectedSourceIds.set(newSelected); + } + } + + toggleAllTarget(event: Event) { + if (this.disabled) return; + + const target = event.target as HTMLInputElement; + const availableItems = this.filteredTargetItems().filter(item => !item.disabled); + + if (target.checked) { + const newSelected = new Set(this.selectedTargetIds()); + availableItems.forEach(item => newSelected.add(item.id)); + this.selectedTargetIds.set(newSelected); + } else { + const newSelected = new Set(this.selectedTargetIds()); + availableItems.forEach(item => newSelected.delete(item.id)); + this.selectedTargetIds.set(newSelected); + } + } + + // Transfer methods + moveSelectedToTarget() { + if (this.disabled) return; + + const itemsToMove = this.selectedSourceItems(); + if (itemsToMove.length === 0) return; + + const newSourceItems = this.sourceItemsSignal().filter( + item => !this.selectedSourceIds().has(item.id) + ); + const newTargetItems = [...this.targetItemsSignal(), ...itemsToMove]; + + this.sourceItemsSignal.set(newSourceItems); + this.targetItemsSignal.set(newTargetItems); + this.selectedSourceIds.set(new Set()); + + // Emit events + this.sourceChange.emit(newSourceItems); + this.targetChange.emit(newTargetItems); + + itemsToMove.forEach(item => { + this.itemMove.emit({ item, from: 'source', to: 'target' }); + }); + } + + moveSelectedToSource() { + if (this.disabled) return; + + const itemsToMove = this.selectedTargetItems(); + if (itemsToMove.length === 0) return; + + const newTargetItems = this.targetItemsSignal().filter( + item => !this.selectedTargetIds().has(item.id) + ); + const newSourceItems = [...this.sourceItemsSignal(), ...itemsToMove]; + + this.sourceItemsSignal.set(newSourceItems); + this.targetItemsSignal.set(newTargetItems); + this.selectedTargetIds.set(new Set()); + + // Emit events + this.sourceChange.emit(newSourceItems); + this.targetChange.emit(newTargetItems); + + itemsToMove.forEach(item => { + this.itemMove.emit({ item, from: 'target', to: 'source' }); + }); + } + + moveAllToTarget() { + if (this.disabled) return; + + const itemsToMove = this.filteredSourceItems().filter(item => !item.disabled); + if (itemsToMove.length === 0) return; + + const newSourceItems = this.sourceItemsSignal().filter(item => item.disabled); + const newTargetItems = [...this.targetItemsSignal(), ...itemsToMove]; + + this.sourceItemsSignal.set(newSourceItems); + this.targetItemsSignal.set(newTargetItems); + this.selectedSourceIds.set(new Set()); + + // Emit events + this.sourceChange.emit(newSourceItems); + this.targetChange.emit(newTargetItems); + + itemsToMove.forEach(item => { + this.itemMove.emit({ item, from: 'source', to: 'target' }); + }); + } + + moveAllToSource() { + if (this.disabled) return; + + const itemsToMove = this.filteredTargetItems().filter(item => !item.disabled); + if (itemsToMove.length === 0) return; + + const newTargetItems = this.targetItemsSignal().filter(item => item.disabled); + const newSourceItems = [...this.sourceItemsSignal(), ...itemsToMove]; + + this.sourceItemsSignal.set(newSourceItems); + this.targetItemsSignal.set(newTargetItems); + this.selectedTargetIds.set(new Set()); + + // Emit events + this.sourceChange.emit(newSourceItems); + this.targetChange.emit(newTargetItems); + + itemsToMove.forEach(item => { + this.itemMove.emit({ item, from: 'target', to: 'source' }); + }); + } + + // Keyboard navigation + handleSourceKeydown(event: KeyboardEvent) { + this.handleListKeydown(event, this.filteredSourceItems(), 'source'); + } + + handleTargetKeydown(event: KeyboardEvent) { + this.handleListKeydown(event, this.filteredTargetItems(), 'target'); + } + + handleItemKeydown(event: KeyboardEvent, item: TransferListItem, list: 'source' | 'target') { + if (event.key === ' ' || event.key === 'Enter') { + event.preventDefault(); + if (list === 'source') { + this.toggleSourceItem(item); + } else { + this.toggleTargetItem(item); + } + } + } + + private handleListKeydown(event: KeyboardEvent, items: TransferListItem[], list: 'source' | 'target') { + const currentFocus = document.activeElement as HTMLElement; + const listItems = Array.from(currentFocus.parentElement?.querySelectorAll('.ui-transfer-list__item[tabindex="0"]') || []) as HTMLElement[]; + + if (listItems.length === 0) return; + + const currentIndex = listItems.indexOf(currentFocus); + let nextIndex = currentIndex; + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + nextIndex = Math.min(currentIndex + 1, listItems.length - 1); + break; + case 'ArrowUp': + event.preventDefault(); + nextIndex = Math.max(currentIndex - 1, 0); + break; + case 'Home': + event.preventDefault(); + nextIndex = 0; + break; + case 'End': + event.preventDefault(); + nextIndex = listItems.length - 1; + break; + } + + if (nextIndex !== currentIndex) { + listItems[nextIndex]?.focus(); + } + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/feedback/alert/alert.component.scss b/projects/ui-essentials/src/lib/components/feedback/alert/alert.component.scss index d6dd823..105893a 100644 --- a/projects/ui-essentials/src/lib/components/feedback/alert/alert.component.scss +++ b/projects/ui-essentials/src/lib/components/feedback/alert/alert.component.scss @@ -9,7 +9,7 @@ // Layout & Spacing padding: $semantic-spacing-component-md; margin-bottom: $semantic-spacing-component-sm; - gap: $semantic-spacing-component-sm; + gap: 16px; // Visual Design background: $semantic-color-surface-elevated; @@ -30,7 +30,7 @@ // Size Variants &--sm { padding: $semantic-spacing-component-sm; - gap: $semantic-spacing-component-xs; + gap: 12px; font-family: map-get($semantic-typography-body-small, font-family); font-size: map-get($semantic-typography-body-small, font-size); font-weight: map-get($semantic-typography-body-small, font-weight); @@ -43,7 +43,7 @@ &--lg { padding: $semantic-spacing-component-lg; - gap: $semantic-spacing-component-md; + gap: 20px; font-family: map-get($semantic-typography-body-large, font-family); font-size: map-get($semantic-typography-body-large, font-size); font-weight: map-get($semantic-typography-body-large, font-weight); @@ -121,7 +121,7 @@ &__icon { flex-shrink: 0; color: $semantic-color-text-secondary; - font-size: $semantic-sizing-icon-inline; + font-size: 1.25rem; // Increased from $semantic-sizing-icon-inline for better visibility margin-top: 2px; // Slight optical alignment } @@ -132,10 +132,10 @@ &__title { margin: 0 0 $semantic-spacing-content-line-tight 0; - font-family: map-get($semantic-typography-label, font-family); - font-size: map-get($semantic-typography-label, font-size); - font-weight: map-get($semantic-typography-label, font-weight); - line-height: map-get($semantic-typography-label, line-height); + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); color: $semantic-color-text-primary; &--bold { diff --git a/projects/ui-essentials/src/lib/components/feedback/index.ts b/projects/ui-essentials/src/lib/components/feedback/index.ts index 66d6892..d4d8ad1 100644 --- a/projects/ui-essentials/src/lib/components/feedback/index.ts +++ b/projects/ui-essentials/src/lib/components/feedback/index.ts @@ -4,5 +4,6 @@ export * from "./skeleton-loader"; export * from "./empty-state"; export * from "./loading-spinner"; export * from "./progress-circle"; +export * from "./snackbar"; export * from "./toast"; // export * from "./theme-switcher.component"; // Temporarily disabled due to CSS variable issues diff --git a/projects/ui-essentials/src/lib/components/feedback/snackbar/index.ts b/projects/ui-essentials/src/lib/components/feedback/snackbar/index.ts new file mode 100644 index 0000000..082d25f --- /dev/null +++ b/projects/ui-essentials/src/lib/components/feedback/snackbar/index.ts @@ -0,0 +1 @@ +export * from './snackbar.component'; \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/feedback/snackbar/snackbar.component.scss b/projects/ui-essentials/src/lib/components/feedback/snackbar/snackbar.component.scss new file mode 100644 index 0000000..4a71c71 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/feedback/snackbar/snackbar.component.scss @@ -0,0 +1,235 @@ +@use "../../../../../../shared-ui/src/styles/semantic/index" as *; + +.ui-snackbar { + // Core Structure + display: flex; + align-items: center; + position: fixed; + bottom: $semantic-spacing-layout-section-md; + left: 50%; + transform: translateX(-50%); + z-index: $semantic-z-index-modal; + max-width: 90vw; + width: auto; + min-width: 300px; + + // Layout & Spacing + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + gap: $semantic-spacing-component-sm; + + // Visual Design + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-secondary; + border-radius: $semantic-border-radius-lg; + box-shadow: $semantic-shadow-elevation-4; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + // Transitions + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + // Size Variants + &--sm { + min-height: $semantic-sizing-button-height-sm; + padding: $semantic-spacing-component-xs $semantic-spacing-component-md; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &--md { + min-height: $semantic-sizing-button-height-md; + padding: $semantic-spacing-component-md $semantic-spacing-component-lg; + } + + &--lg { + min-height: $semantic-sizing-button-height-lg; + padding: $semantic-spacing-component-lg; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + + // Color Variants + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-color: $semantic-color-primary; + } + + &--success { + background: $semantic-color-success; + color: $semantic-color-on-success; + border-color: $semantic-color-success; + } + + &--warning { + background: $semantic-color-warning; + color: $semantic-color-on-warning; + border-color: $semantic-color-warning; + } + + &--danger { + background: $semantic-color-danger; + color: $semantic-color-on-danger; + border-color: $semantic-color-danger; + } + + &--info { + background: $semantic-color-info; + color: $semantic-color-on-info; + border-color: $semantic-color-info; + } + + // Position Variants + &--top { + top: $semantic-spacing-layout-section-md; + bottom: auto; + } + + &--left { + left: $semantic-spacing-layout-section-md; + transform: translateX(0); + } + + &--right { + right: $semantic-spacing-layout-section-md; + left: auto; + transform: translateX(0); + } + + // Animation States + &--entering { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + + &--exiting { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + + // BEM Elements + &__content { + flex: 1; + min-width: 0; + + &--multiline { + padding: $semantic-spacing-content-line-tight 0; + } + } + + &__message { + color: inherit; + margin: 0; + overflow-wrap: break-word; + } + + &__actions { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + margin-left: $semantic-spacing-component-sm; + flex-shrink: 0; + } + + &__action { + background: transparent; + border: none; + padding: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-sm; + cursor: pointer; + transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Use inherited text color with opacity for actions + color: inherit; + font-weight: $semantic-typography-font-weight-medium; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + background-color: rgba(255, 255, 255, 0.2); + } + } + + &__dismiss { + background: transparent; + border: none; + padding: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-sm; + cursor: pointer; + margin-left: $semantic-spacing-component-sm; + color: inherit; + display: flex; + align-items: center; + justify-content: center; + transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover { + background-color: rgba(255, 255, 255, 0.1); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + background-color: rgba(255, 255, 255, 0.2); + } + } + + // Icon styling + &__icon { + flex-shrink: 0; + color: inherit; + font-size: $semantic-sizing-icon-inline; + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-md - 1) { + bottom: $semantic-spacing-layout-section-sm; + left: $semantic-spacing-layout-section-sm; + right: $semantic-spacing-layout-section-sm; + transform: translateX(0); + max-width: none; + + &--left, + &--right { + left: $semantic-spacing-layout-section-sm; + right: $semantic-spacing-layout-section-sm; + transform: translateX(0); + } + } + + @media (max-width: $semantic-breakpoint-sm - 1) { + padding: $semantic-spacing-component-sm; + bottom: $semantic-spacing-layout-section-xs; + left: $semantic-spacing-layout-section-xs; + right: $semantic-spacing-layout-section-xs; + + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + + &__actions { + margin-left: $semantic-spacing-component-xs; + gap: $semantic-spacing-component-xs; + } + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/feedback/snackbar/snackbar.component.ts b/projects/ui-essentials/src/lib/components/feedback/snackbar/snackbar.component.ts new file mode 100644 index 0000000..dc87881 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/feedback/snackbar/snackbar.component.ts @@ -0,0 +1,243 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnDestroy, inject } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { IconDefinition } from '@fortawesome/free-solid-svg-icons'; +import { + faCheckCircle, + faExclamationTriangle, + faExclamationCircle, + faInfoCircle, + faTimes +} from '@fortawesome/free-solid-svg-icons'; + +type SnackbarSize = 'sm' | 'md' | 'lg'; +type SnackbarVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info'; +type SnackbarPosition = 'bottom-center' | 'bottom-left' | 'bottom-right' | 'top-center' | 'top-left' | 'top-right'; + +export interface SnackbarAction { + label: string; + handler: () => void; + disabled?: boolean; +} + +@Component({ + selector: 'ui-snackbar', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.Default, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (showIcon && snackbarIcon) { + + + } + +
+
+ {{ message }} + +
+
+ + @if (actions && actions.length > 0) { +
+ @for (action of actions; track action.label) { + + } +
+ } + + @if (dismissible) { + + } +
+ `, + styleUrl: './snackbar.component.scss' +}) +export class SnackbarComponent implements OnInit, OnDestroy { + @Input() size: SnackbarSize = 'md'; + @Input() variant: SnackbarVariant = 'default'; + @Input() message = ''; + @Input() showIcon = false; + @Input() dismissible = true; + @Input() autoDismiss = true; + @Input() duration = 4000; // 4 seconds - shorter than toast + @Input() position: SnackbarPosition = 'bottom-center'; + @Input() dismissLabel = 'Dismiss snackbar'; + @Input() role = 'status'; + @Input() ariaLive: 'polite' | 'assertive' | 'off' = 'polite'; + @Input() actions: SnackbarAction[] = []; + @Input() isMultiline = false; + + @Output() dismissed = new EventEmitter(); + @Output() expired = new EventEmitter(); + @Output() shown = new EventEmitter(); + @Output() hidden = new EventEmitter(); + @Output() actionClicked = new EventEmitter(); + + // Icons + readonly faCheckCircle = faCheckCircle; + readonly faExclamationTriangle = faExclamationTriangle; + readonly faExclamationCircle = faExclamationCircle; + readonly faInfoCircle = faInfoCircle; + readonly faTimes = faTimes; + + // Generate unique ID for accessibility + readonly snackbarId = Math.random().toString(36).substr(2, 9); + + // State management + isEntering = false; + isExiting = false; + private autoDismissTimeout?: any; + private enterTimeout?: any; + private exitTimeout?: any; + + get snackbarIcon(): IconDefinition | null { + if (!this.showIcon) return null; + + switch (this.variant) { + case 'success': + return this.faCheckCircle; + case 'warning': + return this.faExclamationTriangle; + case 'danger': + return this.faExclamationCircle; + case 'info': + return this.faInfoCircle; + case 'primary': + case 'default': + default: + return this.faInfoCircle; + } + } + + ngOnInit(): void { + this.show(); + } + + ngOnDestroy(): void { + this.clearTimeouts(); + } + + show(): void { + this.isEntering = true; + + this.enterTimeout = setTimeout(() => { + this.isEntering = false; + this.shown.emit(); + + if (this.autoDismiss && this.duration > 0) { + this.startAutoDismissTimer(); + } + }, 300); // Animation duration + } + + hide(): void { + this.clearTimeouts(); + this.isExiting = true; + + this.exitTimeout = setTimeout(() => { + this.isExiting = false; + this.hidden.emit(); + }, 300); // Animation duration + } + + handleDismiss(): void { + this.hide(); + this.dismissed.emit(); + } + + handleDismissKeydown(event: KeyboardEvent): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleDismiss(); + } + } + + handleActionClick(action: SnackbarAction): void { + if (!action.disabled) { + action.handler(); + this.actionClicked.emit(action); + } + } + + private startAutoDismissTimer(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + } + + this.autoDismissTimeout = setTimeout(() => { + this.hide(); + this.expired.emit(); + }, this.duration); + } + + private clearTimeouts(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + this.autoDismissTimeout = undefined; + } + + if (this.enterTimeout) { + clearTimeout(this.enterTimeout); + this.enterTimeout = undefined; + } + + if (this.exitTimeout) { + clearTimeout(this.exitTimeout); + this.exitTimeout = undefined; + } + } + + // Pause auto-dismiss on mouse enter + onMouseEnter(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + } + } + + // Resume auto-dismiss on mouse leave + onMouseLeave(): void { + if (this.autoDismiss && this.duration > 0 && !this.isExiting) { + this.startAutoDismissTimer(); + } + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/forms/autocomplete/autocomplete.component.scss b/projects/ui-essentials/src/lib/components/forms/autocomplete/autocomplete.component.scss index 0ece8c7..b88a1e5 100644 --- a/projects/ui-essentials/src/lib/components/forms/autocomplete/autocomplete.component.scss +++ b/projects/ui-essentials/src/lib/components/forms/autocomplete/autocomplete.component.scss @@ -14,12 +14,12 @@ } .ui-autocomplete__input { - font-size: 14px; + font-size: 16px; padding: 8px 12px; } .ui-autocomplete__label { - font-size: 12px; + font-size: 14px; } } @@ -29,12 +29,12 @@ } .ui-autocomplete__input { - font-size: 16px; + font-size: 18px; padding: 12px 16px; } .ui-autocomplete__label { - font-size: 14px; + font-size: 16px; } } @@ -44,12 +44,12 @@ } .ui-autocomplete__input { - font-size: 18px; + font-size: 20px; padding: 16px 20px; } .ui-autocomplete__label { - font-size: 16px; + font-size: 18px; } } @@ -226,18 +226,24 @@ background: transparent; color: $semantic-color-text-primary; font-family: inherit; + font-size: 18px; text-align: left; cursor: pointer; transition: all 0.2s ease; &--sm { padding: 8px 12px; - font-size: 14px; + font-size: 16px; + } + + &--md { + padding: 12px 16px; + font-size: 18px; } &--lg { padding: 16px 20px; - font-size: 18px; + font-size: 20px; } &:hover { @@ -330,18 +336,26 @@ } // Responsive Design - @media (max-width: 768px - 1) { + @media (max-width: 767px) { &__dropdown { max-height: 200px; } } - @media (max-width: 576px - 1) { - // Mobile adjustments - &__input { + @media (max-width: 575px) { + // Mobile adjustments - respect size variants + &--sm &__input { font-size: 16px; } + &--md &__input { + font-size: 18px; + } + + &--lg &__input { + font-size: 20px; + } + &__dropdown { max-height: 180px; } diff --git a/projects/ui-essentials/src/lib/components/forms/autocomplete/autocomplete.component.ts b/projects/ui-essentials/src/lib/components/forms/autocomplete/autocomplete.component.ts index eb12d3e..84b4eb0 100644 --- a/projects/ui-essentials/src/lib/components/forms/autocomplete/autocomplete.component.ts +++ b/projects/ui-essentials/src/lib/components/forms/autocomplete/autocomplete.component.ts @@ -29,8 +29,11 @@ export interface AutocompleteOption { template: `
TagInputComponent), + multi: true + } + ], + template: ` +
+ +
+ @for (tag of tags; track tag; let i = $index) { +
+ + +
+ } +
+ + @if (!isMaxTagsReached) { + + } +
+ + @if (errorMessage) { +
+ {{ errorMessage }} +
+ } + + @if (helpText && !errorMessage) { +
+ {{ helpText }} +
+ } + `, + styleUrl: './tag-input.component.scss' +}) +export class TagInputComponent implements ControlValueAccessor { + @Input() size: TagInputSize = 'md'; + @Input() variant: TagInputVariant = 'outlined'; + @Input() disabled = false; + @Input() readonly = false; + @Input() placeholder = 'Add tags...'; + @Input() maxTags?: number; + @Input() maxTagLength?: number; + @Input() allowDuplicates = false; + @Input() trimTags = true; + @Input() separators = [',', ';', '|']; + @Input() ariaLabel = 'Tag input'; + @Input() inputAriaLabel = 'Add new tag'; + @Input() errorMessage?: string; + @Input() helpText?: string; + @Input() class = ''; + + @Output() tagAdd = new EventEmitter(); + @Output() tagRemove = new EventEmitter<{ tag: string; index: number }>(); + @Output() maxTagsReached = new EventEmitter(); + @Output() duplicateTag = new EventEmitter(); + @Output() tagValidationError = new EventEmitter<{ tag: string; error: string }>(); + + @ViewChild('inputElement') inputElement!: ElementRef; + + // FontAwesome icon + faXmark = faXmark; + + // Internal state + tags: string[] = []; + inputValue = ''; + + // ControlValueAccessor + private onChange = (value: string[]) => {}; + private onTouched = () => {}; + + get containerClasses(): string { + return [ + 'ui-tag-input', + `ui-tag-input--${this.size}`, + `ui-tag-input--${this.variant}`, + this.disabled ? 'ui-tag-input--disabled' : '', + this.errorMessage ? 'ui-tag-input--error' : '', + this.isMaxTagsReached ? 'ui-tag-input--max-reached' : '', + this.class + ].filter(Boolean).join(' '); + } + + get chipSize(): 'sm' | 'md' | 'lg' { + return this.size; + } + + get chipVariant(): 'filled' | 'outlined' { + return this.variant === 'outlined' ? 'outlined' : 'filled'; + } + + get isMaxTagsReached(): boolean { + return this.maxTags ? this.tags.length >= this.maxTags : false; + } + + get effectivePlaceholder(): string { + if (this.tags.length === 0) { + return this.placeholder; + } + if (this.isMaxTagsReached) { + return `Maximum ${this.maxTags} tags reached`; + } + return ''; + } + + get errorId(): string { + return `ui-tag-input-error-${Math.random().toString(36).substr(2, 9)}`; + } + + get helpId(): string { + return `ui-tag-input-help-${Math.random().toString(36).substr(2, 9)}`; + } + + get ariaDescribedBy(): string { + const ids: string[] = []; + if (this.errorMessage) { + ids.push(this.errorId); + } else if (this.helpText) { + ids.push(this.helpId); + } + return ids.join(' ') || ''; + } + + // ControlValueAccessor implementation + writeValue(value: string[]): void { + this.tags = Array.isArray(value) ? [...value] : []; + } + + registerOnChange(fn: (value: string[]) => void): void { + this.onChange = fn; + } + + registerOnTouched(fn: () => void): void { + this.onTouched = fn; + } + + setDisabledState(isDisabled: boolean): void { + this.disabled = isDisabled; + } + + // Event handlers + handleInput(event: Event): void { + const target = event.target as HTMLInputElement; + this.inputValue = target.value; + + // Check for separators + const lastChar = this.inputValue[this.inputValue.length - 1]; + if (this.separators.includes(lastChar)) { + this.addTagFromInput(); + } + } + + handleKeydown(event: KeyboardEvent): void { + switch (event.key) { + case 'Enter': + case 'Tab': + if (this.inputValue.trim()) { + event.preventDefault(); + this.addTagFromInput(); + } + break; + + case 'Backspace': + if (!this.inputValue && this.tags.length > 0) { + event.preventDefault(); + this.removeTag(this.tags.length - 1); + } + break; + + case 'Escape': + this.inputValue = ''; + if (this.inputElement) { + this.inputElement.nativeElement.value = ''; + } + break; + } + } + + handleBlur(): void { + this.onTouched(); + if (this.inputValue.trim()) { + this.addTagFromInput(); + } + } + + handleFocus(): void { + // Focus handling if needed + } + + focusInput(): void { + if (!this.disabled && !this.readonly && !this.isMaxTagsReached && this.inputElement) { + this.inputElement.nativeElement.focus(); + } + } + + // Tag management + addTag(tag: string): boolean { + const processedTag = this.trimTags ? tag.trim() : tag; + + if (!processedTag) { + return false; + } + + // Check max length + if (this.maxTagLength && processedTag.length > this.maxTagLength) { + this.tagValidationError.emit({ + tag: processedTag, + error: `Tag exceeds maximum length of ${this.maxTagLength} characters` + }); + return false; + } + + // Check max tags + if (this.isMaxTagsReached) { + this.maxTagsReached.emit(); + return false; + } + + // Check duplicates + if (!this.allowDuplicates && this.tags.includes(processedTag)) { + this.duplicateTag.emit(processedTag); + return false; + } + + // Add tag + this.tags = [...this.tags, processedTag]; + this.onChange(this.tags); + this.tagAdd.emit(processedTag); + + return true; + } + + removeTag(index: number): void { + if (index >= 0 && index < this.tags.length) { + const removedTag = this.tags[index]; + this.tags = this.tags.filter((_, i) => i !== index); + this.onChange(this.tags); + this.tagRemove.emit({ tag: removedTag, index }); + + // Focus input after removal + setTimeout(() => this.focusInput(), 0); + } + } + + private addTagFromInput(): void { + if (this.inputValue) { + // Remove separator characters + let cleanValue = this.inputValue; + this.separators.forEach(sep => { + cleanValue = cleanValue.replace(new RegExp(`\\${sep}`, 'g'), ''); + }); + + if (this.addTag(cleanValue)) { + this.inputValue = ''; + if (this.inputElement) { + this.inputElement.nativeElement.value = ''; + } + } + } + } + + // Public methods + clear(): void { + this.tags = []; + this.inputValue = ''; + if (this.inputElement) { + this.inputElement.nativeElement.value = ''; + } + this.onChange(this.tags); + } + + addTagProgrammatically(tag: string): boolean { + return this.addTag(tag); + } + + getTagsAsString(separator = ', '): string { + return this.tags.join(separator); + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/layout/container/container.component.scss b/projects/ui-essentials/src/lib/components/layout/container/container.component.scss index 88a5194..a20abbe 100644 --- a/projects/ui-essentials/src/lib/components/layout/container/container.component.scss +++ b/projects/ui-essentials/src/lib/components/layout/container/container.component.scss @@ -4,12 +4,11 @@ display: block; position: relative; width: 100%; - margin-left: auto; - margin-right: auto; + margin: 0 auto; + box-sizing: border-box; // Base padding - padding-left: $semantic-spacing-component-md; - padding-right: $semantic-spacing-component-md; + padding: 0 $semantic-spacing-component-md; // Size variants &--xs { diff --git a/projects/ui-essentials/src/lib/components/layout/container/container.component.ts b/projects/ui-essentials/src/lib/components/layout/container/container.component.ts index 5126a75..ec04106 100644 --- a/projects/ui-essentials/src/lib/components/layout/container/container.component.ts +++ b/projects/ui-essentials/src/lib/components/layout/container/container.component.ts @@ -18,19 +18,7 @@ type ScrollDirection = 'y' | 'x' | 'both'; encapsulation: ViewEncapsulation.None, template: `
{ + const classes: Record = { + 'ui-container': true, + [`ui-container--${this.size}`]: true, + 'ui-container--responsive': this.responsive + }; + + if (this.variant !== 'default') { + classes[`ui-container--${this.variant}`] = true; + } + + if (this.background !== 'transparent') { + classes[`ui-container--${this.background}`] = true; + } + + if (this.padding) { + classes[`ui-container--padding-${this.padding}`] = true; + } + + if (this.paddingY) { + classes[`ui-container--padding-y-${this.paddingY}`] = true; + } + + if (this.flexDirection && this.variant === 'flex') { + classes[`ui-container--flex-${this.flexDirection}`] = true; + } + + if (this.flexJustify && this.variant === 'flex') { + classes[`ui-container--flex-${this.flexJustify}`] = true; + } + + if (this.gridColumns && this.variant === 'grid') { + classes[`ui-container--grid-${this.gridColumns}`] = true; + } + + if (this.scrollable === 'y') { + classes['ui-container--scrollable'] = true; + } else if (this.scrollable === 'x') { + classes['ui-container--scrollable-x'] = true; + } else if (this.scrollable === 'both') { + classes['ui-container--scrollable-both'] = true; + } + + return classes; + } } \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/layout/spacer/spacer.component.scss b/projects/ui-essentials/src/lib/components/layout/spacer/spacer.component.scss index a04ca43..ef896ac 100644 --- a/projects/ui-essentials/src/lib/components/layout/spacer/spacer.component.scss +++ b/projects/ui-essentials/src/lib/components/layout/spacer/spacer.component.scss @@ -4,58 +4,60 @@ display: block; position: relative; - // Default spacing - width: tokens.$semantic-spacing-md; - height: tokens.$semantic-spacing-md; - - // Size variants - &--xs { - width: tokens.$semantic-spacing-xs; - height: tokens.$semantic-spacing-xs; - } - - &--sm { - width: tokens.$semantic-spacing-sm; - height: tokens.$semantic-spacing-sm; - } - - &--md { + // Default spacing (only when not flexible) + &:not(.ui-spacer--flexible) { width: tokens.$semantic-spacing-md; height: tokens.$semantic-spacing-md; } - &--lg { + // Size variants (only when not flexible) + &--xs:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-xs; + height: tokens.$semantic-spacing-xs; + } + + &--sm:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-sm; + height: tokens.$semantic-spacing-sm; + } + + &--md:not(.ui-spacer--flexible) { + width: tokens.$semantic-spacing-md; + height: tokens.$semantic-spacing-md; + } + + &--lg:not(.ui-spacer--flexible) { width: tokens.$semantic-spacing-lg; height: tokens.$semantic-spacing-lg; } - &--xl { + &--xl:not(.ui-spacer--flexible) { width: tokens.$semantic-spacing-xl; height: tokens.$semantic-spacing-xl; } - &--2xl { + &--2xl:not(.ui-spacer--flexible) { width: tokens.$semantic-spacing-2xl; height: tokens.$semantic-spacing-2xl; } - &--3xl { + &--3xl:not(.ui-spacer--flexible) { width: tokens.$semantic-spacing-3xl; height: tokens.$semantic-spacing-3xl; } - &--4xl { + &--4xl:not(.ui-spacer--flexible) { width: tokens.$semantic-spacing-4xl; height: tokens.$semantic-spacing-4xl; } - &--5xl { + &--5xl:not(.ui-spacer--flexible) { width: tokens.$semantic-spacing-5xl; height: tokens.$semantic-spacing-5xl; } - // Directional variants - &--horizontal { + // Directional variants (only when not flexible) + &--horizontal:not(.ui-spacer--flexible) { height: 0; width: tokens.$semantic-spacing-md; @@ -70,7 +72,7 @@ &.ui-spacer--5xl { width: tokens.$semantic-spacing-5xl; } } - &--vertical { + &--vertical:not(.ui-spacer--flexible) { width: 0; height: tokens.$semantic-spacing-md; @@ -87,15 +89,21 @@ // Flexible spacer (grows to fill available space) &--flexible { - flex: 1; + flex: 1 1 0; &.ui-spacer--horizontal { - width: auto !important; + height: 0; min-width: tokens.$semantic-spacing-xs; } &.ui-spacer--vertical { - height: auto !important; + width: 0; + min-height: tokens.$semantic-spacing-xs; + } + + // When both directions, allow flex in any direction + &:not(.ui-spacer--horizontal):not(.ui-spacer--vertical) { + min-width: tokens.$semantic-spacing-xs; min-height: tokens.$semantic-spacing-xs; } } diff --git a/projects/ui-essentials/src/lib/components/layout/spacer/spacer.component.ts b/projects/ui-essentials/src/lib/components/layout/spacer/spacer.component.ts index 02c0c68..45145ed 100644 --- a/projects/ui-essentials/src/lib/components/layout/spacer/spacer.component.ts +++ b/projects/ui-essentials/src/lib/components/layout/spacer/spacer.component.ts @@ -15,12 +15,7 @@ type SpacerDirection = 'both' | 'horizontal' | 'vertical'; template: `
@@ -37,4 +32,26 @@ export class SpacerComponent { @Input() debug = false; @Input() customWidth?: string; @Input() customHeight?: string; + + getClasses(): Record { + const classes: Record = {}; + + if (this.size && !this.variant) { + classes[`ui-spacer--${this.size}`] = true; + } + + if (this.variant) { + classes[`ui-spacer--${this.variant}`] = true; + } + + if (this.direction !== 'both') { + classes[`ui-spacer--${this.direction}`] = true; + } + + classes['ui-spacer--flexible'] = this.flexible; + classes['ui-spacer--responsive'] = this.responsive; + classes['ui-spacer--debug'] = this.debug; + + return classes; + } } \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/media/video-player/video-player.component.scss b/projects/ui-essentials/src/lib/components/media/video-player/video-player.component.scss index a232b00..86bb538 100644 --- a/projects/ui-essentials/src/lib/components/media/video-player/video-player.component.scss +++ b/projects/ui-essentials/src/lib/components/media/video-player/video-player.component.scss @@ -15,12 +15,14 @@ .ui-video-player { position: relative; width: 100%; - max-width: 800px; background: $semantic-color-surface; border-radius: 12px; overflow: hidden; box-shadow: $semantic-shadow-elevation-2; + // Default size (medium) + max-width: 800px; + &--fullscreen { max-width: none !important; width: 100vw !important; @@ -43,26 +45,17 @@ } } - // Size variants + // Size variants - placed after default to override &--sm { max-width: 400px; - .ui-video-player__video-container { - padding-bottom: 56.25%; // 16:9 aspect ratio - } } &--md { max-width: 800px; - .ui-video-player__video-container { - padding-bottom: 56.25%; // 16:9 aspect ratio - } } &--lg { max-width: 1200px; - .ui-video-player__video-container { - padding-bottom: 56.25%; // 16:9 aspect ratio - } } // Variant styles @@ -73,13 +66,17 @@ } &--theater { - max-width: none; width: 100%; background: #000; .ui-video-player__video-container { padding-bottom: 42.85%; // 21:9 aspect ratio for theater mode } + + // Only remove max-width if no size variant is applied + &:not(.ui-video-player--sm):not(.ui-video-player--md):not(.ui-video-player--lg) { + max-width: none; + } } &__video-container { @@ -367,12 +364,17 @@ // Responsive design @media (max-width: 768px) { - max-width: none; + // On mobile, make size variants responsive but maintain relative sizing + &--sm { + max-width: 320px; + } + + &--md { + max-width: 600px; + } - &--sm, - &--md, &--lg { - max-width: none; + max-width: 100%; } &__controls { diff --git a/projects/ui-essentials/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.scss b/projects/ui-essentials/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.scss new file mode 100644 index 0000000..4e2c5aa --- /dev/null +++ b/projects/ui-essentials/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.scss @@ -0,0 +1,244 @@ +@use "../../../../../../shared-ui/src/styles/semantic/index" as *; + +.ui-bottom-navigation { + // Core Structure + display: flex; + position: fixed; + bottom: 0; + left: 0; + right: 0; + z-index: $semantic-z-index-dropdown; + align-items: center; + justify-content: space-around; + + // Layout & Spacing + padding: $semantic-spacing-component-sm; + min-height: $semantic-sizing-touch-target; + + // Visual Design + background: $semantic-color-surface-primary; + border-top: $semantic-border-width-1 solid $semantic-color-border-primary; + box-shadow: $semantic-shadow-elevation-3; + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Size Variants + &--sm { + min-height: $semantic-sizing-button-height-sm; + padding: $semantic-spacing-component-xs; + } + + &--md { + min-height: $semantic-sizing-touch-target; + padding: $semantic-spacing-component-sm; + } + + &--lg { + min-height: $semantic-sizing-button-height-lg; + padding: $semantic-spacing-component-md; + } + + // Variant Styles + &--primary { + background: $semantic-color-primary; + border-top-color: $semantic-color-primary; + color: $semantic-color-on-primary; + + .ui-bottom-navigation__item { + color: $semantic-color-on-primary; + + &--active { + background: rgba(255, 255, 255, 0.1); + } + + &:hover:not(&--active) { + background: rgba(255, 255, 255, 0.05); + } + } + } + + &--secondary { + background: $semantic-color-secondary; + border-top-color: $semantic-color-secondary; + color: $semantic-color-on-secondary; + + .ui-bottom-navigation__item { + color: $semantic-color-on-secondary; + + &--active { + background: rgba(255, 255, 255, 0.1); + } + + &:hover:not(&--active) { + background: rgba(255, 255, 255, 0.05); + } + } + } + + // Elevated variant + &--elevated { + box-shadow: $semantic-shadow-elevation-4; + } + + // Hidden variant for dynamic showing/hiding + &--hidden { + transform: translateY(100%); + } + + // BEM Element - Navigation Item + &__item { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + flex: 1; + min-width: $semantic-sizing-touch-minimum; + min-height: $semantic-sizing-touch-minimum; + padding: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-sm; + cursor: pointer; + text-decoration: none; + color: $semantic-color-text-secondary; + + // Typography + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + + // Transitions + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + // Interactive States + &:hover:not(&--disabled):not(&--active) { + background: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active:not(&--disabled) { + transform: scale(0.98); + } + + // Active State + &--active { + background: $semantic-color-interactive-primary; + color: $semantic-color-primary; + font-weight: $semantic-typography-font-weight-semibold; + } + + // Disabled State + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + // Badge modifier + &--has-badge { + position: relative; + } + } + + // BEM Element - Icon + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + margin-bottom: $semantic-spacing-content-line-tight; + + // Icon sizing variants + .ui-bottom-navigation--sm & { + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + } + + .ui-bottom-navigation--lg & { + width: $semantic-sizing-icon-button; + height: $semantic-sizing-icon-button; + } + } + + // BEM Element - Label + &__label { + text-align: center; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 100%; + + // Typography variants by size + .ui-bottom-navigation--sm & { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + .ui-bottom-navigation--lg & { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + } + } + + // BEM Element - Badge + &__badge { + position: absolute; + top: -$semantic-spacing-component-xs; + right: -$semantic-spacing-component-xs; + min-width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + display: flex; + align-items: center; + justify-content: center; + background: $semantic-color-danger; + color: $semantic-color-on-danger; + border-radius: $semantic-border-radius-full; + padding: 0 $semantic-spacing-component-xs; + + // Typography + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: $semantic-typography-font-weight-semibold; + line-height: map-get($semantic-typography-caption, line-height); + + // Hide if no content + &:empty { + min-width: 8px; + height: 8px; + padding: 0; + } + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-sm - 1) { + padding: $semantic-spacing-component-xs; + + .ui-bottom-navigation__item { + padding: $semantic-spacing-component-xs * 0.5; + } + + .ui-bottom-navigation__label { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + } + + @media (max-width: $semantic-breakpoint-md - 1) { + // Ensure touch targets remain accessible on tablets + .ui-bottom-navigation__item { + min-height: $semantic-sizing-touch-target; + } + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.ts b/projects/ui-essentials/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.ts new file mode 100644 index 0000000..907af54 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/navigation/bottom-navigation/bottom-navigation.component.ts @@ -0,0 +1,144 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; + +export type BottomNavigationSize = 'sm' | 'md' | 'lg'; +export type BottomNavigationVariant = 'default' | 'primary' | 'secondary'; + +export interface BottomNavigationItem { + id: string; + label: string; + icon?: string; + badge?: string | number; + disabled?: boolean; + href?: string; + route?: string; +} + +@Component({ + selector: 'ui-bottom-navigation', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, + styleUrl: './bottom-navigation.component.scss' +}) +export class BottomNavigationComponent { + @Input() size: BottomNavigationSize = 'md'; + @Input() variant: BottomNavigationVariant = 'default'; + @Input() elevated = false; + @Input() hidden = false; + @Input() items: BottomNavigationItem[] = []; + @Input() activeItemId?: string; + @Input() ariaLabel = 'Bottom navigation'; + + @Output() itemClicked = new EventEmitter(); + @Output() activeItemChanged = new EventEmitter(); + + handleItemClick(event: MouseEvent, item: BottomNavigationItem): void { + if (item.disabled) { + event.preventDefault(); + return; + } + + // If it's a route navigation, prevent default to let router handle it + if (item.route && !item.href) { + event.preventDefault(); + } + + // Update active item if not disabled + if (this.activeItemId !== item.id) { + this.activeItemChanged.emit(item.id); + } + + // Emit item clicked event + this.itemClicked.emit(item); + } + + handleItemKeydown(event: KeyboardEvent, item: BottomNavigationItem): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.handleItemClick(event as any, item); + } else if (event.key === 'ArrowLeft' || event.key === 'ArrowRight') { + event.preventDefault(); + this.navigateToAdjacentItem(event.key === 'ArrowLeft' ? -1 : 1); + } + } + + private navigateToAdjacentItem(direction: number): void { + const enabledItems = this.items.filter(item => !item.disabled); + const currentIndex = enabledItems.findIndex(item => item.id === this.activeItemId); + + if (currentIndex === -1) return; + + const nextIndex = (currentIndex + direction + enabledItems.length) % enabledItems.length; + const nextItem = enabledItems[nextIndex]; + + if (nextItem) { + this.activeItemChanged.emit(nextItem.id); + + // Focus the next item + setTimeout(() => { + const itemElement = document.querySelector( + `.ui-bottom-navigation__item[aria-current="page"]` + ) as HTMLElement; + itemElement?.focus(); + }); + } + } + + getBadgeDisplay(badge: string | number): string { + if (typeof badge === 'number' && badge > 99) { + return '99+'; + } + return badge.toString(); + } + + getBadgeAriaLabel(badge: string | number): string { + if (typeof badge === 'number') { + return badge > 99 ? 'More than 99 notifications' : `${badge} notifications`; + } + return `Badge: ${badge}`; + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/navigation/bottom-navigation/index.ts b/projects/ui-essentials/src/lib/components/navigation/bottom-navigation/index.ts new file mode 100644 index 0000000..ef6937c --- /dev/null +++ b/projects/ui-essentials/src/lib/components/navigation/bottom-navigation/index.ts @@ -0,0 +1 @@ +export * from './bottom-navigation.component'; \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/navigation/index.ts b/projects/ui-essentials/src/lib/components/navigation/index.ts index 1d0b865..5e8338f 100644 --- a/projects/ui-essentials/src/lib/components/navigation/index.ts +++ b/projects/ui-essentials/src/lib/components/navigation/index.ts @@ -1,5 +1,7 @@ export * from './appbar'; +export * from './bottom-navigation'; export * from './breadcrumb'; export * from './menu'; export * from './pagination'; +export * from './stepper'; export * from './tab-group'; \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/navigation/menu/menu-item.component.scss b/projects/ui-essentials/src/lib/components/navigation/menu/menu-item.component.scss index 17cc40b..ed6fb59 100644 --- a/projects/ui-essentials/src/lib/components/navigation/menu/menu-item.component.scss +++ b/projects/ui-essentials/src/lib/components/navigation/menu/menu-item.component.scss @@ -131,7 +131,7 @@ // ========================================================================== &--indent { - margin-left: $semantic-spacing-component-xl; + margin-left: $semantic-spacing-component-md; } &--with-divider { diff --git a/projects/ui-essentials/src/lib/components/navigation/menu/menu-submenu.component.scss b/projects/ui-essentials/src/lib/components/navigation/menu/menu-submenu.component.scss index 025df2b..5987f06 100644 --- a/projects/ui-essentials/src/lib/components/navigation/menu/menu-submenu.component.scss +++ b/projects/ui-essentials/src/lib/components/navigation/menu/menu-submenu.component.scss @@ -12,13 +12,23 @@ display: flex; flex-direction: column; width: 100%; + position: relative; + + // Ensure parent menu item doesn't interfere with submenu hover states + > ui-menu-item { + position: relative; + z-index: 1; + } } .submenu-content { overflow: hidden; transition: max-height $semantic-motion-duration-slow $semantic-motion-easing-ease-in-out, opacity $semantic-motion-duration-normal $semantic-motion-easing-ease-in-out; // Apply indentation to the entire submenu content block - margin-left: calc($semantic-spacing-component-lg + $semantic-spacing-component-md); + margin-left: calc($semantic-spacing-component-lg + $semantic-spacing-component-sm); + // Fix z-index and positioning to prevent hover state conflicts + position: relative; + z-index: 1; .menu-submenu { //border-left: 2px solid $semantic-color-border-secondary; @@ -26,6 +36,7 @@ background-color: $semantic-color-surface-elevated; box-shadow: none; position: relative; + z-index: 2; } } @@ -54,6 +65,10 @@ .submenu-content { .menu-item { position: relative; + // Ensure submenu items have proper stacking context + z-index: 3; + // Clear any potential interaction with parent items + pointer-events: auto; &::before { content: ''; @@ -82,10 +97,17 @@ width: 100% !important; margin-right: 0 !important; margin-bottom: 0; + // Create isolated interaction area to prevent hover conflicts + isolation: isolate; &:not(:last-child) { margin-bottom: 0 !important; } + + // Ensure hover states don't bleed to adjacent items + &:hover { + z-index: 4; + } } } diff --git a/projects/ui-essentials/src/lib/components/navigation/stepper/index.ts b/projects/ui-essentials/src/lib/components/navigation/stepper/index.ts new file mode 100644 index 0000000..3222386 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/navigation/stepper/index.ts @@ -0,0 +1 @@ +export * from './stepper.component'; \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/navigation/stepper/stepper.component.scss b/projects/ui-essentials/src/lib/components/navigation/stepper/stepper.component.scss new file mode 100644 index 0000000..b6c5a98 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/navigation/stepper/stepper.component.scss @@ -0,0 +1,318 @@ +@use "../../../../../../shared-ui/src/styles/semantic" as *; + +.ui-stepper { + display: flex; + width: 100%; + position: relative; + + // Layout variants + &--horizontal { + flex-direction: row; + align-items: center; + } + + &--vertical { + flex-direction: column; + align-items: flex-start; + } +} + +.ui-stepper__step { + display: flex; + position: relative; + + .ui-stepper--horizontal & { + flex-direction: column; + align-items: center; + flex: 1; + + &:not(:last-child) { + margin-right: $semantic-spacing-component-lg; + } + } + + .ui-stepper--vertical & { + flex-direction: row; + align-items: flex-start; + width: 100%; + + &:not(:last-child) { + margin-bottom: $semantic-spacing-component-xl; + } + } +} + +.ui-stepper__step-header { + display: flex; + align-items: center; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + border-radius: $semantic-border-radius-md; + + .ui-stepper--horizontal & { + flex-direction: column; + text-align: center; + padding: $semantic-spacing-component-sm; + } + + .ui-stepper--vertical & { + flex-direction: row; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + margin-bottom: $semantic-spacing-component-sm; + } + + &:hover:not(.ui-stepper__step-header--disabled) { + background: $semantic-color-surface-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-brand-primary; + outline-offset: 2px; + } + + &--disabled { + cursor: not-allowed; + opacity: $semantic-opacity-disabled; + } +} + +.ui-stepper__step-indicator { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-touch-target; + height: $semantic-sizing-touch-target; + border-radius: $semantic-border-radius-full; + border: $semantic-border-width-2 solid $semantic-color-border-primary; + background: $semantic-color-surface-primary; + color: $semantic-color-text-secondary; + font-weight: $semantic-typography-font-weight-semibold; + font-size: $semantic-typography-font-size-sm; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + position: relative; + flex-shrink: 0; + + .ui-stepper--horizontal & { + margin-bottom: $semantic-spacing-component-sm; + } + + .ui-stepper--vertical & { + margin-right: $semantic-spacing-component-md; + } + + // Step states + &--pending { + border-color: $semantic-color-border-primary; + background: $semantic-color-surface-primary; + color: $semantic-color-text-secondary; + } + + &--current { + border-color: $semantic-color-brand-primary; + background: $semantic-color-brand-primary; + color: $semantic-color-on-brand-primary; + box-shadow: $semantic-shadow-elevation-2; + } + + &--completed { + border-color: $semantic-color-success; + background: $semantic-color-success; + color: $semantic-color-on-success; + } + + &--error { + border-color: $semantic-color-danger; + background: $semantic-color-danger; + color: $semantic-color-on-danger; + } + + &--disabled { + border-color: $semantic-color-border-primary; + background: $semantic-color-surface-disabled; + color: $semantic-color-text-disabled; + } +} + +.ui-stepper__step-content { + display: flex; + flex-direction: column; + + .ui-stepper--horizontal & { + align-items: center; + text-align: center; + } + + .ui-stepper--vertical & { + align-items: flex-start; + flex: 1; + } +} + +.ui-stepper__step-title { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: $semantic-typography-font-weight-semibold; + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + margin: 0; + + .ui-stepper__step-header--disabled & { + color: $semantic-color-text-disabled; + } +} + +.ui-stepper__step-description { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + margin: $semantic-spacing-content-line-tight 0 0 0; + + .ui-stepper__step-header--disabled & { + color: $semantic-color-text-disabled; + } +} + +.ui-stepper__connector { + position: absolute; + background: $semantic-color-border-primary; + transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease; + + .ui-stepper--horizontal & { + top: calc(#{$semantic-sizing-touch-target / 2} + #{$semantic-spacing-component-sm} - 1px); + left: calc(#{$semantic-sizing-touch-target / 2} + #{$semantic-spacing-component-sm} + #{$semantic-sizing-touch-target / 2} + #{$semantic-sizing-touch-target * 2} - 13px); + right: calc(-#{$semantic-spacing-component-lg} - #{$semantic-spacing-component-sm} + #{$semantic-sizing-touch-target / 2}); + height: 2px; + transform: none; + } + + .ui-stepper--vertical & { + left: calc(#{$semantic-sizing-touch-target / 2} + #{$semantic-spacing-component-sm} + #{$semantic-spacing-component-sm} - 2px); + top: calc(#{$semantic-sizing-touch-target} + #{$semantic-spacing-component-sm} + 2px); + height: calc(100% - 2px); + width: 2px; + } + + &--completed { + background: $semantic-color-success; + } + + &--error { + background: $semantic-color-danger; + } + + &--incoming { + .ui-stepper--horizontal & { + left: -16px; + width: calc(#{$semantic-sizing-touch-target / 2} + #{$semantic-spacing-component-sm} + 4px + #{$semantic-sizing-touch-target} + 20px); + } + + .ui-stepper--vertical & { + top: calc(-#{$semantic-spacing-component-xl} + #{$semantic-spacing-component-sm}); + height: calc(#{$semantic-spacing-component-xl} - #{$semantic-spacing-component-sm * 2} - 4px); + } + } +} + +// Size variants +.ui-stepper--sm { + .ui-stepper__step-indicator { + width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + font-size: $semantic-typography-font-size-xs; + } + + .ui-stepper__step-title { + font-size: map-get($semantic-typography-body-small, font-size); + } + + .ui-stepper__step-description { + font-size: $semantic-typography-font-size-xs; + } +} + +.ui-stepper--lg { + .ui-stepper__step-indicator { + width: $semantic-sizing-button-height-lg; + height: $semantic-sizing-button-height-lg; + font-size: $semantic-typography-font-size-md; + } + + .ui-stepper__step-title { + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + } +} + +// Alternative visual style +.ui-stepper--outlined { + .ui-stepper__step-indicator { + &--current { + background: $semantic-color-surface-primary; + color: $semantic-color-brand-primary; + border-color: $semantic-color-brand-primary; + border-width: 3px; + } + } +} + +// Dense variant for compact layouts +.ui-stepper--dense { + .ui-stepper__step { + .ui-stepper--horizontal & { + &:not(:last-child) { + margin-right: $semantic-spacing-component-md; + } + } + + .ui-stepper--vertical & { + &:not(:last-child) { + margin-bottom: $semantic-spacing-component-lg; + } + } + } + + .ui-stepper__step-header { + padding: $semantic-spacing-component-xs; + } + + .ui-stepper__step-indicator { + .ui-stepper--vertical & { + margin-right: $semantic-spacing-component-sm; + } + } +} + +// Responsive design +@media (max-width: 768px) { + .ui-stepper--horizontal { + .ui-stepper__step { + &:not(:last-child) { + margin-right: $semantic-spacing-component-sm; + } + } + + .ui-stepper__step-description { + display: none; + } + + .ui-stepper__step-title { + font-size: map-get($semantic-typography-body-small, font-size); + } + } + + .ui-stepper__step-indicator { + width: $semantic-sizing-button-height-sm; + height: $semantic-sizing-button-height-sm; + font-size: $semantic-typography-font-size-xs; + } +} + +@media (max-width: 480px) { + .ui-stepper--horizontal { + .ui-stepper__step-title { + font-size: $semantic-typography-font-size-xs; + } + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/navigation/stepper/stepper.component.ts b/projects/ui-essentials/src/lib/components/navigation/stepper/stepper.component.ts new file mode 100644 index 0000000..5863bb0 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/navigation/stepper/stepper.component.ts @@ -0,0 +1,271 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faCheck, faTimes } from '@fortawesome/free-solid-svg-icons'; + +export interface StepperStep { + id: string; + title: string; + description?: string; + completed?: boolean; + current?: boolean; + error?: boolean; + disabled?: boolean; + optional?: boolean; +} + +export type StepperOrientation = 'horizontal' | 'vertical'; +export type StepperSize = 'sm' | 'md' | 'lg'; +export type StepperVariant = 'default' | 'outlined'; + +@Component({ + selector: 'ui-stepper', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ @for (step of steps; track step.id; let i = $index; let isLast = $last) { +
+
+ +
+ @if (step.completed && !step.error) { + + } @else if (step.error) { + + } @else { + {{ i + 1 }} + } +
+ +
+

{{ step.title }}

+ @if (step.description) { +

{{ step.description }}

+ } +
+
+ + @if (!isLast) { +
+ } + @if (i > 0) { +
+ } +
+ } +
+ `, + styleUrl: './stepper.component.scss' +}) +export class StepperComponent { + @Input() steps: StepperStep[] = []; + @Input() orientation: StepperOrientation = 'horizontal'; + @Input() size: StepperSize = 'md'; + @Input() variant: StepperVariant = 'default'; + @Input() dense = false; + @Input() clickable = false; + @Input() linear = true; + + @Output() stepClick = new EventEmitter<{step: StepperStep, index: number}>(); + @Output() stepChange = new EventEmitter<{step: StepperStep, index: number}>(); + + checkIcon = faCheck; + errorIcon = faTimes; + + getStepperClasses(): string { + return [ + 'ui-stepper', + `ui-stepper--${this.orientation}`, + `ui-stepper--${this.size}`, + this.variant === 'outlined' ? 'ui-stepper--outlined' : '', + this.dense ? 'ui-stepper--dense' : '' + ].filter(Boolean).join(' '); + } + + getStepHeaderClasses(step: StepperStep): string { + return [ + 'ui-stepper__step-header', + step.disabled ? 'ui-stepper__step-header--disabled' : '' + ].filter(Boolean).join(' '); + } + + getStepIndicatorClasses(step: StepperStep): string { + let state = 'pending'; + + if (step.disabled) { + state = 'disabled'; + } else if (step.error) { + state = 'error'; + } else if (step.completed) { + state = 'completed'; + } else if (step.current) { + state = 'current'; + } + + return [ + 'ui-stepper__step-indicator', + `ui-stepper__step-indicator--${state}` + ].filter(Boolean).join(' '); + } + + getConnectorClasses(currentStep: StepperStep, nextStep: StepperStep): string { + let state = 'default'; + + if (currentStep.error) { + state = 'error'; + } else if (currentStep.completed && !nextStep.error) { + state = 'completed'; + } + + return [ + 'ui-stepper__connector', + state !== 'default' ? `ui-stepper__connector--${state}` : '' + ].filter(Boolean).join(' '); + } + + getTabIndex(step: StepperStep): number { + if (!this.clickable || step.disabled) { + return -1; + } + + if (this.linear) { + // In linear mode, only allow interaction with current step and completed steps + return step.current || step.completed ? 0 : -1; + } + + return 0; + } + + onStepClick(step: StepperStep, index: number): void { + if (!this.clickable || step.disabled) { + return; + } + + if (this.linear && !step.current && !step.completed) { + // In linear mode, don't allow clicking on future steps + return; + } + + this.stepClick.emit({ step, index }); + + if (!step.current) { + this.updateCurrentStep(index); + this.stepChange.emit({ step, index }); + } + } + + onStepKeydown(event: KeyboardEvent, step: StepperStep, index: number): void { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + this.onStepClick(step, index); + } + + // Arrow navigation + if (this.clickable && ['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) { + event.preventDefault(); + this.handleArrowNavigation(event.key, index); + } + } + + private handleArrowNavigation(key: string, currentIndex: number): void { + const isHorizontal = this.orientation === 'horizontal'; + const isForward = (isHorizontal && key === 'ArrowRight') || (!isHorizontal && key === 'ArrowDown'); + const isBackward = (isHorizontal && key === 'ArrowLeft') || (!isHorizontal && key === 'ArrowUp'); + + if (!isForward && !isBackward) return; + + const direction = isForward ? 1 : -1; + let targetIndex = currentIndex + direction; + + // Find next available step + while (targetIndex >= 0 && targetIndex < this.steps.length) { + const targetStep = this.steps[targetIndex]; + + if (!targetStep.disabled && this.getTabIndex(targetStep) >= 0) { + this.focusStep(targetIndex); + break; + } + + targetIndex += direction; + } + } + + private focusStep(index: number): void { + // This would require ViewChild to focus the element + // For now, emit an event that parent can handle + const step = this.steps[index]; + if (step) { + this.stepClick.emit({ step, index }); + } + } + + private updateCurrentStep(newCurrentIndex: number): void { + this.steps.forEach((step, index) => { + step.current = index === newCurrentIndex; + }); + } + + // Public methods for programmatic control + public goToStep(index: number): void { + if (index >= 0 && index < this.steps.length) { + const step = this.steps[index]; + if (!step.disabled && (!this.linear || step.completed || step.current || index === 0)) { + this.updateCurrentStep(index); + this.stepChange.emit({ step, index }); + } + } + } + + public markStepCompleted(index: number, completed = true): void { + if (index >= 0 && index < this.steps.length) { + this.steps[index].completed = completed; + this.steps[index].error = false; // Clear error when marking completed + } + } + + public markStepError(index: number, error = true): void { + if (index >= 0 && index < this.steps.length) { + this.steps[index].error = error; + if (error) { + this.steps[index].completed = false; // Clear completed when marking error + } + } + } + + public nextStep(): void { + const currentIndex = this.steps.findIndex(step => step.current); + if (currentIndex >= 0 && currentIndex < this.steps.length - 1) { + // Mark current step as completed if not already + if (!this.steps[currentIndex].completed && !this.steps[currentIndex].error) { + this.markStepCompleted(currentIndex); + } + + this.goToStep(currentIndex + 1); + } + } + + public previousStep(): void { + const currentIndex = this.steps.findIndex(step => step.current); + if (currentIndex > 0) { + this.goToStep(currentIndex - 1); + } + } + + public resetStepper(): void { + this.steps.forEach((step, index) => { + step.current = index === 0; + step.completed = false; + step.error = false; + }); + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette-item.component.scss b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette-item.component.scss new file mode 100644 index 0000000..f6294ec --- /dev/null +++ b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette-item.component.scss @@ -0,0 +1,167 @@ +@use '../../../../../../shared-ui/src/styles/semantic' as *; + +.ui-command-palette-item { + display: flex; + align-items: center; + width: 100%; + padding: $semantic-spacing-component-sm; + margin: 0; + border: none; + border-radius: $semantic-border-radius-sm; + background: transparent; + color: $semantic-color-text-primary; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + text-align: left; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + + &:hover, + &--selected { + background: $semantic-color-surface-elevated; + box-shadow: $semantic-shadow-elevation-1; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + &__content { + display: flex; + align-items: center; + width: 100%; + gap: $semantic-spacing-component-sm; + } + + &__icon { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + flex-shrink: 0; + color: $semantic-color-text-secondary; + transition: color $semantic-motion-duration-fast $semantic-motion-easing-ease; + } + + &:hover &__icon, + &--selected &__icon { + color: $semantic-color-primary; + } + + &__text { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: $semantic-spacing-content-line-tight; + } + + &__title { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__description { + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + color: $semantic-color-text-secondary; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + &__highlight { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + padding: 0 2px; + border-radius: $semantic-border-radius-sm; + font-weight: $semantic-typography-font-weight-semibold; + } + + &__shortcut { + display: flex; + align-items: center; + gap: $semantic-spacing-content-line-tight; + flex-shrink: 0; + margin-left: auto; + } + + &__kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: $semantic-sizing-touch-minimum; + height: $semantic-sizing-button-height-sm; + padding: 0 $semantic-spacing-component-xs; + background: $semantic-color-surface-secondary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-sm; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-secondary; + text-transform: uppercase; + box-shadow: $semantic-shadow-elevation-1; + } + + &__plus { + color: $semantic-color-text-tertiary; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + } + + // Hover state for shortcut keys + &:hover &__kbd, + &--selected &__kbd { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-border-primary; + color: $semantic-color-text-primary; + } + + // Focus state improvements + &:focus-visible { + .ui-command-palette-item__icon { + color: $semantic-color-primary; + } + + .ui-command-palette-item__kbd { + background: $semantic-color-surface-elevated; + border-color: $semantic-color-focus; + color: $semantic-color-text-primary; + } + } + + // Responsive adjustments + @media (max-width: calc(#{$semantic-breakpoint-md} - 1px)) { + padding: $semantic-spacing-component-xs; + + &__content { + gap: $semantic-spacing-component-xs; + } + + &__shortcut { + display: none; // Hide shortcuts on smaller screens + } + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette-item.component.ts b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette-item.component.ts new file mode 100644 index 0000000..7f90984 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette-item.component.ts @@ -0,0 +1,127 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { CommandSearchResult } from './command-palette.types'; + +@Component({ + selector: 'ui-command-palette-item', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, + styleUrl: './command-palette-item.component.scss' +}) +export class CommandPaletteItemComponent { + @Input({ required: true }) result!: CommandSearchResult; + @Input() selected = false; + + @Output() itemClick = new EventEmitter(); + @Output() itemHover = new EventEmitter(); + + handleClick(): void { + if (!this.result.command.disabled) { + this.itemClick.emit(this.result); + } + } + + handleMouseEnter(): void { + this.itemHover.emit(this.result); + } + + getHighlightedSegments(): Array<{ text: string; highlighted: boolean }> { + const title = this.result.command.title; + const ranges = this.result.highlightRanges; + + if (ranges.length === 0) { + return [{ text: title, highlighted: false }]; + } + + const segments: Array<{ text: string; highlighted: boolean }> = []; + let currentIndex = 0; + + // Sort ranges by start position + const sortedRanges = [...ranges].sort((a, b) => a.start - b.start); + + for (const range of sortedRanges) { + // Add non-highlighted text before this range + if (currentIndex < range.start) { + segments.push({ + text: title.substring(currentIndex, range.start), + highlighted: false + }); + } + + // Add highlighted text + segments.push({ + text: title.substring(range.start, range.end), + highlighted: true + }); + + currentIndex = range.end; + } + + // Add remaining non-highlighted text + if (currentIndex < title.length) { + segments.push({ + text: title.substring(currentIndex), + highlighted: false + }); + } + + return segments; + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.component.scss b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.component.scss new file mode 100644 index 0000000..bc18b21 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.component.scss @@ -0,0 +1,383 @@ +@use '../../../../../../shared-ui/src/styles/semantic' as *; + +.ui-command-palette { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + z-index: $semantic-z-index-modal; + display: none; + align-items: flex-start; + justify-content: center; + padding: $semantic-spacing-layout-section-lg $semantic-spacing-component-md; + + &--open, + &--entering, + &--leaving { + display: flex; + } + + &__backdrop { + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: $semantic-color-backdrop; + opacity: 0; + animation: backdropEnter $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + &--leaving &__backdrop { + animation: backdropExit $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + &__container { + position: relative; + width: 100%; + max-width: 640px; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-lg; + box-shadow: $semantic-shadow-modal; + overflow: hidden; + transform: translateY(-20px); + opacity: 0; + animation: containerEnter $semantic-motion-duration-normal $semantic-motion-easing-spring forwards; + } + + &--leaving &__container { + animation: containerExit $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + // Size variants + &--md &__container { + max-width: 480px; + } + + &--lg &__container { + max-width: 640px; + } + + &--xl &__container { + max-width: 800px; + } + + // Search Section + &__search { + position: relative; + display: flex; + align-items: center; + padding: $semantic-spacing-component-md; + border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle; + background: $semantic-color-surface-primary; + } + + &__search-icon { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-navigation; + height: $semantic-sizing-icon-navigation; + margin-right: $semantic-spacing-component-sm; + color: $semantic-color-text-secondary; + flex-shrink: 0; + } + + &__input { + flex: 1; + padding: 0; + border: none; + background: transparent; + font-family: map-get($semantic-typography-input, font-family); + font-size: map-get($semantic-typography-input, font-size); + font-weight: map-get($semantic-typography-input, font-weight); + line-height: map-get($semantic-typography-input, line-height); + color: $semantic-color-text-primary; + + &::placeholder { + color: $semantic-color-text-tertiary; + } + + &:focus { + outline: none; + } + + // Remove search input styling + &::-webkit-search-cancel-button, + &::-webkit-search-decoration { + -webkit-appearance: none; + } + + &[type="search"] { + -webkit-appearance: textfield; + } + } + + &__clear { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-touch-minimum; + height: $semantic-sizing-touch-minimum; + padding: 0; + margin-left: $semantic-spacing-component-xs; + border: none; + border-radius: $semantic-border-radius-sm; + background: transparent; + color: $semantic-color-text-secondary; + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + font-size: $semantic-typography-font-size-lg; + font-weight: $semantic-typography-font-weight-bold; + + &:hover { + background: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + } + + // Results Section + &__results { + max-height: 400px; + overflow-y: auto; + padding: $semantic-spacing-component-xs 0; + background: $semantic-color-surface-primary; + + // Custom scrollbar + &::-webkit-scrollbar { + width: 8px; + } + + &::-webkit-scrollbar-track { + background: $semantic-color-surface-secondary; + } + + &::-webkit-scrollbar-thumb { + background: $semantic-color-border-secondary; + border-radius: $semantic-border-radius-sm; + + &:hover { + background: $semantic-color-border-primary; + } + } + } + + // Group styling + &__group { + &:not(:first-child) { + margin-top: $semantic-spacing-component-md; + padding-top: $semantic-spacing-component-sm; + border-top: $semantic-border-width-1 solid $semantic-color-border-subtle; + } + } + + &__group-header { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-component-xs $semantic-spacing-component-md; + margin-bottom: $semantic-spacing-component-xs; + } + + &__group-icon { + display: flex; + align-items: center; + justify-content: center; + width: $semantic-sizing-icon-inline; + height: $semantic-sizing-icon-inline; + color: $semantic-color-text-tertiary; + } + + &__group-title { + flex: 1; + margin: 0; + font-family: map-get($semantic-typography-label, font-family); + font-size: map-get($semantic-typography-label, font-size); + font-weight: map-get($semantic-typography-label, font-weight); + line-height: map-get($semantic-typography-label, line-height); + color: $semantic-color-text-secondary; + text-transform: uppercase; + letter-spacing: 0.05em; + } + + &__group-count { + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-tertiary; + background: $semantic-color-surface-secondary; + padding: 2px $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-full; + min-width: 20px; + text-align: center; + } + + // Empty state + &__empty { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: $semantic-spacing-layout-section-md; + text-align: center; + } + + &__empty-icon { + display: flex; + align-items: center; + justify-content: center; + width: 48px; + height: 48px; + margin-bottom: $semantic-spacing-component-md; + color: $semantic-color-text-tertiary; + opacity: $semantic-opacity-subtle; + } + + &__empty-text { + margin: 0; + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-secondary; + } + + // Footer + &__footer { + display: flex; + align-items: center; + gap: $semantic-spacing-component-lg; + padding: $semantic-spacing-component-sm $semantic-spacing-component-md; + border-top: $semantic-border-width-1 solid $semantic-color-border-subtle; + background: $semantic-color-surface-secondary; + justify-content: center; + } + + &__footer-section { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + } + + &__footer-kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 24px; + height: 20px; + padding: 0 $semantic-spacing-component-xs; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-sm; + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-secondary; + box-shadow: $semantic-shadow-elevation-1; + } + + &__footer-section span { + font-family: map-get($semantic-typography-caption, font-family); + font-size: map-get($semantic-typography-caption, font-size); + font-weight: map-get($semantic-typography-caption, font-weight); + line-height: map-get($semantic-typography-caption, line-height); + color: $semantic-color-text-tertiary; + } + + // Responsive design + @media (max-width: calc(#{$semantic-breakpoint-md} - 1px)) { + padding: $semantic-spacing-component-md $semantic-spacing-component-sm; + + &__container { + max-width: none; + } + + &__search { + padding: $semantic-spacing-component-sm; + } + + &__results { + max-height: 300px; + } + + &__footer { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + gap: $semantic-spacing-component-md; + } + } + + @media (max-width: calc(#{$semantic-breakpoint-sm} - 1px)) { + padding: $semantic-spacing-component-sm; + + &__footer-section:not(:first-child) { + display: none; // Hide extra keyboard hints on mobile + } + } + + // Reduce motion for users who prefer it + @media (prefers-reduced-motion: reduce) { + &__backdrop, + &__container { + animation: none !important; + } + + &--entering &__backdrop { + opacity: $semantic-opacity-backdrop; + } + + &--entering &__container { + transform: none; + opacity: 1; + } + } +} + +// Animations +@keyframes backdropEnter { + from { + opacity: 0; + } + to { + opacity: $semantic-opacity-backdrop; + } +} + +@keyframes backdropExit { + from { + opacity: $semantic-opacity-backdrop; + } + to { + opacity: 0; + } +} + +@keyframes containerEnter { + from { + transform: translateY(-20px); + opacity: 0; + } + to { + transform: translateY(0); + opacity: 1; + } +} + +@keyframes containerExit { + from { + transform: translateY(0); + opacity: 1; + } + to { + transform: translateY(-10px); + opacity: 0; + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.component.ts b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.component.ts new file mode 100644 index 0000000..c0359d1 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.component.ts @@ -0,0 +1,485 @@ +import { + Component, + Input, + Output, + EventEmitter, + ChangeDetectionStrategy, + ViewEncapsulation, + OnInit, + OnDestroy, + ViewChild, + ElementRef, + inject, + signal, + computed, + effect +} from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faSearch, faClock, faFolder, faTerminal } from '@fortawesome/free-solid-svg-icons'; +import { CommandPaletteService } from './command-palette.service'; +import { CommandPaletteItemComponent } from './command-palette-item.component'; +import { + CommandSearchResult, + CommandGroup, + CommandCategory, + CommandExecutionContext +} from './command-palette.types'; + +export type CommandPaletteSize = 'md' | 'lg' | 'xl'; + +@Component({ + selector: 'ui-command-palette', + standalone: true, + imports: [CommonModule, FormsModule, FontAwesomeModule, CommandPaletteItemComponent], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + + `, + styleUrl: './command-palette.component.scss' +}) +export class CommandPaletteComponent implements OnInit, OnDestroy { + @ViewChild('searchInput') searchInput!: ElementRef; + + @Input() size: CommandPaletteSize = 'lg'; + @Input() showFooter = true; + @Input() autoFocus = true; + + @Output() opened = new EventEmitter(); + @Output() closed = new EventEmitter(); + @Output() commandExecuted = new EventEmitter<{ commandId: string; context: CommandExecutionContext }>(); + + // Inject services + readonly commandService = inject(CommandPaletteService); + + // Icons + readonly faSearch = faSearch; + readonly faClock = faClock; + readonly faFolder = faFolder; + readonly faTerminal = faTerminal; + + // State signals + private _isOpen = signal(false); + private _isEntering = signal(false); + private _isLeaving = signal(false); + private _searchQuery = signal(''); + private _selectedIndex = signal(0); + private _searchResults = signal([]); + + // Computed signals + readonly isOpen = this._isOpen.asReadonly(); + readonly isEntering = this._isEntering.asReadonly(); + readonly isLeaving = this._isLeaving.asReadonly(); + readonly searchQuery = this._searchQuery.asReadonly(); + readonly selectedIndex = this._selectedIndex.asReadonly(); + readonly searchResults = this._searchResults.asReadonly(); + readonly isVisible = computed(() => this._isOpen() || this._isEntering() || this._isLeaving()); + + readonly groupedResults = computed(() => { + const results = this._searchResults(); + if (!results.length || this._searchQuery()) { + return []; + } + + const groups = new Map(); + + results.forEach(result => { + const category = result.command.category; + const categoryResults = groups.get(category) || []; + categoryResults.push(result); + groups.set(category, categoryResults); + }); + + return Array.from(groups.entries()).map(([category, commands]) => ({ + category, + title: this.getCategoryTitle(category), + commands: [] + })).sort((a, b) => this.getCategoryOrder(a.category) - this.getCategoryOrder(b.category)); + }); + + // Internal properties + readonly paletteId = `palette-${Math.random().toString(36).substr(2, 9)}`; + private previousActiveElement: Element | null = null; + + constructor() { + // Auto-update search results when query changes + effect(() => { + const query = this._searchQuery(); + const results = this.commandService.searchCommands(query); + this._searchResults.set(results); + this._selectedIndex.set(0); + }); + } + + ngOnInit(): void { + // Initial search to show recent commands + this.updateSearchResults(); + } + + ngOnDestroy(): void { + this.restoreFocus(); + } + + open(): void { + if (this._isOpen()) return; + + this.storePreviousFocus(); + this._isOpen.set(true); + this._isEntering.set(true); + this.updateSearchResults(); + + // Focus input after animation + setTimeout(() => { + this._isEntering.set(false); + if (this.autoFocus && this.searchInput) { + this.searchInput.nativeElement.focus(); + } + this.opened.emit(); + }, 150); + } + + close(): void { + if (!this._isOpen()) return; + + this._isLeaving.set(true); + + setTimeout(() => { + this._isOpen.set(false); + this._isLeaving.set(false); + this._searchQuery.set(''); + this._selectedIndex.set(0); + this.restoreFocus(); + this.closed.emit(); + }, 150); + } + + toggle(): void { + if (this._isOpen()) { + this.close(); + } else { + this.open(); + } + } + + handleSearchInput(event: Event): void { + const target = event.target as HTMLInputElement; + this._searchQuery.set(target.value); + } + + clearSearch(): void { + this._searchQuery.set(''); + if (this.searchInput) { + this.searchInput.nativeElement.focus(); + } + } + + handleKeydown(event: KeyboardEvent): void { + const results = this._searchResults(); + + switch (event.key) { + case 'ArrowDown': + event.preventDefault(); + this.navigateDown(); + break; + + case 'ArrowUp': + event.preventDefault(); + this.navigateUp(); + break; + + case 'Enter': + event.preventDefault(); + this.executeSelectedCommand(); + break; + + case 'Escape': + event.preventDefault(); + this.close(); + break; + + case 'Tab': + event.preventDefault(); + // Tab could cycle through results or close + if (event.shiftKey) { + this.navigateUp(); + } else { + this.navigateDown(); + } + break; + } + } + + private navigateDown(): void { + const results = this._searchResults(); + if (results.length === 0) return; + + const currentIndex = this._selectedIndex(); + const nextIndex = currentIndex >= results.length - 1 ? 0 : currentIndex + 1; + this._selectedIndex.set(nextIndex); + } + + private navigateUp(): void { + const results = this._searchResults(); + if (results.length === 0) return; + + const currentIndex = this._selectedIndex(); + const prevIndex = currentIndex <= 0 ? results.length - 1 : currentIndex - 1; + this._selectedIndex.set(prevIndex); + } + + executeSelectedCommand(): void { + const results = this._searchResults(); + const selectedResult = results[this._selectedIndex()]; + + if (selectedResult) { + this.executeCommand(selectedResult); + } + } + + async executeCommand(result: CommandSearchResult): Promise { + const context: CommandExecutionContext = { + source: 'click', + timestamp: new Date() + }; + + try { + await this.commandService.executeCommand(result.command.id, context); + this.commandExecuted.emit({ commandId: result.command.id, context }); + this.close(); + } catch (error) { + console.error('Failed to execute command:', error); + } + } + + setSelectedIndex(index: number): void { + this._selectedIndex.set(index); + } + + getActiveDescendantId(): string | null { + const results = this._searchResults(); + const selectedResult = results[this._selectedIndex()]; + return selectedResult ? `command-${selectedResult.command.id}` : null; + } + + getCategoryIcon(category: CommandCategory) { + const icons = { + [CommandCategory.RECENT]: faClock, + [CommandCategory.NAVIGATION]: faFolder, + [CommandCategory.ACTIONS]: faTerminal, + [CommandCategory.SETTINGS]: faTerminal, + [CommandCategory.SEARCH]: faSearch, + [CommandCategory.HELP]: faTerminal, + [CommandCategory.FILE]: faFolder, + [CommandCategory.EDIT]: faTerminal, + [CommandCategory.VIEW]: faTerminal, + [CommandCategory.TOOLS]: faTerminal + }; + return icons[category] || faTerminal; + } + + getCategoryTitle(category: CommandCategory): string { + const titles = { + [CommandCategory.RECENT]: 'Recent', + [CommandCategory.NAVIGATION]: 'Navigation', + [CommandCategory.ACTIONS]: 'Actions', + [CommandCategory.SETTINGS]: 'Settings', + [CommandCategory.SEARCH]: 'Search', + [CommandCategory.HELP]: 'Help', + [CommandCategory.FILE]: 'File', + [CommandCategory.EDIT]: 'Edit', + [CommandCategory.VIEW]: 'View', + [CommandCategory.TOOLS]: 'Tools' + }; + return titles[category] || 'Other'; + } + + getCategoryOrder(category: CommandCategory): number { + const orders = { + [CommandCategory.RECENT]: 0, + [CommandCategory.NAVIGATION]: 1, + [CommandCategory.FILE]: 2, + [CommandCategory.EDIT]: 3, + [CommandCategory.VIEW]: 4, + [CommandCategory.ACTIONS]: 5, + [CommandCategory.TOOLS]: 6, + [CommandCategory.SEARCH]: 7, + [CommandCategory.SETTINGS]: 8, + [CommandCategory.HELP]: 9 + }; + return orders[category] || 99; + } + + getResultsForGroup(group: { category: CommandCategory }): CommandSearchResult[] { + return this._searchResults().filter(result => result.command.category === group.category); + } + + getGlobalIndex(group: { category: CommandCategory }, localIndex: number): number { + const results = this._searchResults(); + let globalIndex = 0; + + for (const result of results) { + if (result.command.category === group.category) { + if (localIndex === 0) { + return globalIndex; + } + localIndex--; + } + globalIndex++; + } + + return 0; + } + + private updateSearchResults(): void { + const query = this._searchQuery(); + const results = this.commandService.searchCommands(query); + this._searchResults.set(results); + this._selectedIndex.set(0); + } + + private storePreviousFocus(): void { + this.previousActiveElement = document.activeElement; + } + + private restoreFocus(): void { + if (this.previousActiveElement && 'focus' in this.previousActiveElement) { + (this.previousActiveElement as HTMLElement).focus(); + } + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.service.ts b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.service.ts new file mode 100644 index 0000000..987358e --- /dev/null +++ b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.service.ts @@ -0,0 +1,353 @@ +import { Injectable, signal } from '@angular/core'; +import { + Command, + CommandGroup, + CommandSearchResult, + CommandPaletteConfig, + CommandCategory, + RecentCommand, + CommandExecutionContext +} from './command-palette.types'; + +@Injectable({ + providedIn: 'root' +}) +export class CommandPaletteService { + private _commands = signal([]); + private _recentCommands = signal([]); + private _config = signal({ + maxResults: 20, + showCategories: true, + showShortcuts: true, + showRecent: true, + recentLimit: 5, + placeholder: 'Search commands...', + noResultsMessage: 'No commands found', + fuzzySearchThreshold: 0.3 + }); + + readonly commands = this._commands.asReadonly(); + readonly recentCommands = this._recentCommands.asReadonly(); + readonly config = this._config.asReadonly(); + + private readonly RECENT_COMMANDS_KEY = 'command-palette-recent'; + + constructor() { + this.loadRecentCommands(); + } + + /** + * Register a new command + */ + registerCommand(command: Command): void { + const commands = this._commands(); + const existingIndex = commands.findIndex(c => c.id === command.id); + + if (existingIndex >= 0) { + // Update existing command + const updated = [...commands]; + updated[existingIndex] = command; + this._commands.set(updated); + } else { + // Add new command + this._commands.set([...commands, command]); + } + } + + /** + * Register multiple commands at once + */ + registerCommands(commands: Command[]): void { + commands.forEach(command => this.registerCommand(command)); + } + + /** + * Unregister a command by ID + */ + unregisterCommand(commandId: string): void { + const commands = this._commands(); + this._commands.set(commands.filter(c => c.id !== commandId)); + } + + /** + * Get command by ID + */ + getCommand(commandId: string): Command | undefined { + return this._commands().find(c => c.id === commandId); + } + + /** + * Get all commands grouped by category + */ + getCommandsByCategory(): CommandGroup[] { + const commands = this._commands().filter(c => c.visible !== false); + const groups = new Map(); + + commands.forEach(command => { + const categoryCommands = groups.get(command.category) || []; + categoryCommands.push(command); + groups.set(command.category, categoryCommands); + }); + + return Array.from(groups.entries()).map(([category, categoryCommands]) => ({ + category, + title: this.getCategoryTitle(category), + commands: categoryCommands.sort((a, b) => (a.order || 0) - (b.order || 0)), + order: this.getCategoryOrder(category) + })).sort((a, b) => (a.order || 0) - (b.order || 0)); + } + + /** + * Search commands with fuzzy matching + */ + searchCommands(query: string): CommandSearchResult[] { + if (!query.trim()) { + return this.getRecentCommandResults(); + } + + const commands = this._commands().filter(c => c.visible !== false && !c.disabled); + const results: CommandSearchResult[] = []; + const normalizedQuery = query.toLowerCase().trim(); + + for (const command of commands) { + const searchText = this.buildSearchText(command); + const score = this.calculateFuzzyScore(normalizedQuery, searchText); + + if (score >= (this._config().fuzzySearchThreshold || 0.3)) { + const highlightRanges = this.findHighlightRanges(normalizedQuery, command.title); + results.push({ + command, + score, + highlightRanges + }); + } + } + + return results + .sort((a, b) => b.score - a.score) + .slice(0, this._config().maxResults || 20); + } + + /** + * Execute a command and track usage + */ + async executeCommand(commandId: string, context: CommandExecutionContext): Promise { + const command = this.getCommand(commandId); + if (!command || command.disabled) { + return; + } + + try { + await command.handler(); + this.trackCommandUsage(commandId); + } catch (error) { + console.error('Command execution failed:', error); + throw error; + } + } + + /** + * Update configuration + */ + updateConfig(config: Partial): void { + this._config.set({ ...this._config(), ...config }); + } + + /** + * Clear all commands + */ + clearCommands(): void { + this._commands.set([]); + } + + /** + * Clear recent commands + */ + clearRecentCommands(): void { + this._recentCommands.set([]); + localStorage.removeItem(this.RECENT_COMMANDS_KEY); + } + + private buildSearchText(command: Command): string { + const parts = [ + command.title, + command.description || '', + ...command.keywords, + ...(command.aliases || []) + ]; + return parts.join(' ').toLowerCase(); + } + + private calculateFuzzyScore(query: string, text: string): number { + const queryChars = query.split(''); + const textLower = text.toLowerCase(); + let score = 0; + let queryIndex = 0; + let consecutiveMatches = 0; + + // Exact title match gets highest score + if (text.includes(query)) { + score += 1; + if (text.startsWith(query)) { + score += 0.5; // Bonus for prefix match + } + } + + // Character by character fuzzy matching + for (let i = 0; i < textLower.length && queryIndex < queryChars.length; i++) { + if (textLower[i] === queryChars[queryIndex]) { + score += 0.1; + consecutiveMatches++; + queryIndex++; + + // Bonus for consecutive matches + if (consecutiveMatches > 1) { + score += 0.05 * consecutiveMatches; + } + } else { + consecutiveMatches = 0; + } + } + + // Penalty for incomplete matches + const completionRatio = queryIndex / queryChars.length; + score *= completionRatio; + + return score; + } + + private findHighlightRanges(query: string, text: string): Array<{ start: number; end: number }> { + const ranges: Array<{ start: number; end: number }> = []; + const textLower = text.toLowerCase(); + const queryLower = query.toLowerCase(); + + let startIndex = 0; + let matchIndex = textLower.indexOf(queryLower, startIndex); + + while (matchIndex !== -1) { + ranges.push({ + start: matchIndex, + end: matchIndex + queryLower.length + }); + startIndex = matchIndex + 1; + matchIndex = textLower.indexOf(queryLower, startIndex); + } + + return ranges; + } + + private getRecentCommandResults(): CommandSearchResult[] { + if (!this._config().showRecent) { + return []; + } + + const recentCommands = this._recentCommands() + .sort((a, b) => b.lastUsed.getTime() - a.lastUsed.getTime()) + .slice(0, this._config().recentLimit || 5); + + const results: CommandSearchResult[] = []; + + for (const recent of recentCommands) { + const command = this.getCommand(recent.commandId); + if (command) { + results.push({ + command: { ...command, category: CommandCategory.RECENT }, + score: 1, + highlightRanges: [] + }); + } + } + + return results; + } + + private trackCommandUsage(commandId: string): void { + const recent = this._recentCommands(); + const existingIndex = recent.findIndex(r => r.commandId === commandId); + const now = new Date(); + + let updated: RecentCommand[]; + if (existingIndex >= 0) { + updated = [...recent]; + updated[existingIndex] = { + ...updated[existingIndex], + lastUsed: now, + useCount: updated[existingIndex].useCount + 1 + }; + } else { + updated = [...recent, { + commandId, + lastUsed: now, + useCount: 1 + }]; + } + + // Keep only the most recent commands + const maxRecent = (this._config().recentLimit || 5) * 2; + if (updated.length > maxRecent) { + updated = updated + .sort((a, b) => b.lastUsed.getTime() - a.lastUsed.getTime()) + .slice(0, maxRecent); + } + + this._recentCommands.set(updated); + this.saveRecentCommands(); + } + + private loadRecentCommands(): void { + try { + const stored = localStorage.getItem(this.RECENT_COMMANDS_KEY); + if (stored) { + const parsed = JSON.parse(stored); + const recent = parsed.map((item: any) => ({ + ...item, + lastUsed: new Date(item.lastUsed) + })); + this._recentCommands.set(recent); + } + } catch (error) { + console.warn('Failed to load recent commands:', error); + } + } + + private saveRecentCommands(): void { + try { + const recent = this._recentCommands(); + localStorage.setItem(this.RECENT_COMMANDS_KEY, JSON.stringify(recent)); + } catch (error) { + console.warn('Failed to save recent commands:', error); + } + } + + private getCategoryTitle(category: CommandCategory): string { + const titles: Record = { + [CommandCategory.RECENT]: 'Recent', + [CommandCategory.NAVIGATION]: 'Navigation', + [CommandCategory.ACTIONS]: 'Actions', + [CommandCategory.SETTINGS]: 'Settings', + [CommandCategory.SEARCH]: 'Search', + [CommandCategory.HELP]: 'Help', + [CommandCategory.FILE]: 'File', + [CommandCategory.EDIT]: 'Edit', + [CommandCategory.VIEW]: 'View', + [CommandCategory.TOOLS]: 'Tools' + }; + return titles[category]; + } + + private getCategoryOrder(category: CommandCategory): number { + const orders: Record = { + [CommandCategory.RECENT]: 0, + [CommandCategory.NAVIGATION]: 1, + [CommandCategory.FILE]: 2, + [CommandCategory.EDIT]: 3, + [CommandCategory.VIEW]: 4, + [CommandCategory.ACTIONS]: 5, + [CommandCategory.TOOLS]: 6, + [CommandCategory.SEARCH]: 7, + [CommandCategory.SETTINGS]: 8, + [CommandCategory.HELP]: 9 + }; + return orders[category] || 99; + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.types.ts b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.types.ts new file mode 100644 index 0000000..3b28578 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/overlays/command-palette/command-palette.types.ts @@ -0,0 +1,64 @@ +import { IconDefinition } from '@fortawesome/fontawesome-svg-core'; + +export interface Command { + id: string; + title: string; + description?: string; + icon?: IconDefinition; + category: CommandCategory; + keywords: string[]; + shortcut?: string[]; + handler: () => void | Promise; + visible?: boolean; + disabled?: boolean; + order?: number; + aliases?: string[]; +} + +export interface CommandGroup { + category: CommandCategory; + title: string; + commands: Command[]; + order?: number; +} + +export interface CommandSearchResult { + command: Command; + score: number; + highlightRanges: Array<{ start: number; end: number }>; +} + +export interface CommandPaletteConfig { + maxResults?: number; + showCategories?: boolean; + showShortcuts?: boolean; + showRecent?: boolean; + recentLimit?: number; + placeholder?: string; + noResultsMessage?: string; + fuzzySearchThreshold?: number; +} + +export enum CommandCategory { + NAVIGATION = 'navigation', + ACTIONS = 'actions', + SETTINGS = 'settings', + SEARCH = 'search', + HELP = 'help', + RECENT = 'recent', + FILE = 'file', + EDIT = 'edit', + VIEW = 'view', + TOOLS = 'tools' +} + +export interface RecentCommand { + commandId: string; + lastUsed: Date; + useCount: number; +} + +export interface CommandExecutionContext { + source: 'keyboard' | 'click' | 'programmatic'; + timestamp: Date; +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/command-palette/global-keyboard.directive.ts b/projects/ui-essentials/src/lib/components/overlays/command-palette/global-keyboard.directive.ts new file mode 100644 index 0000000..c8fc5f8 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/overlays/command-palette/global-keyboard.directive.ts @@ -0,0 +1,299 @@ +import { + Directive, + Input, + Output, + EventEmitter, + HostListener, + OnInit, + OnDestroy, + inject +} from '@angular/core'; +import { DOCUMENT } from '@angular/common'; + +export interface KeyboardShortcut { + key: string; + ctrlKey?: boolean; + metaKey?: boolean; + altKey?: boolean; + shiftKey?: boolean; + preventDefault?: boolean; + target?: string; // CSS selector for target elements +} + +export interface KeyboardEvent { + shortcut: KeyboardShortcut; + event: globalThis.KeyboardEvent; +} + +@Directive({ + selector: '[uiGlobalKeyboard]', + standalone: true +}) +export class GlobalKeyboardDirective implements OnInit, OnDestroy { + private document = inject(DOCUMENT) as Document; + + @Input() shortcuts: KeyboardShortcut[] = []; + @Input() enabled = true; + @Input() ignoreInputs = true; + @Input() ignoreContentEditable = true; + + @Output() shortcutTriggered = new EventEmitter(); + + private boundKeydownHandler = this.handleKeydown.bind(this); + + ngOnInit(): void { + if (this.enabled) { + this.document.addEventListener('keydown', this.boundKeydownHandler, true); + } + } + + ngOnDestroy(): void { + this.document.removeEventListener('keydown', this.boundKeydownHandler, true); + } + + private handleKeydown(event: globalThis.KeyboardEvent): void { + if (!this.enabled) return; + + // Skip if typing in form inputs (unless specifically disabled) + if (this.ignoreInputs && this.isTypingInInput(event.target as Element)) { + return; + } + + // Skip if editing content (unless specifically disabled) + if (this.ignoreContentEditable && this.isEditingContent(event.target as Element)) { + return; + } + + // Check each shortcut for a match + for (const shortcut of this.shortcuts) { + if (this.isShortcutMatch(event, shortcut)) { + // Check if target element matches (if specified) + if (shortcut.target && !this.isTargetMatch(event.target as Element, shortcut.target)) { + continue; + } + + // Prevent default behavior if specified + if (shortcut.preventDefault !== false) { + event.preventDefault(); + event.stopPropagation(); + } + + // Emit the shortcut event + this.shortcutTriggered.emit({ + shortcut, + event + }); + + // Stop after first match + break; + } + } + } + + private isShortcutMatch(event: globalThis.KeyboardEvent, shortcut: KeyboardShortcut): boolean { + // Normalize key comparison (case-insensitive for letters) + const eventKey = event.key.toLowerCase(); + const shortcutKey = shortcut.key.toLowerCase(); + + // Handle special cases for common keys + const keyMatch = this.normalizeKey(eventKey) === this.normalizeKey(shortcutKey); + + // Check modifier keys + const ctrlMatch = (shortcut.ctrlKey || false) === event.ctrlKey; + const metaMatch = (shortcut.metaKey || false) === event.metaKey; + const altMatch = (shortcut.altKey || false) === event.altKey; + const shiftMatch = (shortcut.shiftKey || false) === event.shiftKey; + + return keyMatch && ctrlMatch && metaMatch && altMatch && shiftMatch; + } + + private normalizeKey(key: string): string { + // Handle special key mappings + const keyMap: { [key: string]: string } = { + ' ': 'space', + 'arrowup': 'up', + 'arrowdown': 'down', + 'arrowleft': 'left', + 'arrowright': 'right', + 'delete': 'del', + 'escape': 'esc' + }; + + return keyMap[key.toLowerCase()] || key.toLowerCase(); + } + + private isTypingInInput(target: Element | null): boolean { + if (!target) return false; + + const tagName = target.tagName.toLowerCase(); + const inputTypes = ['input', 'textarea', 'select']; + + if (inputTypes.includes(tagName)) { + const element = target as HTMLInputElement | HTMLTextAreaElement; + + // Skip if it's a non-text input + if (tagName === 'input') { + const type = (element as HTMLInputElement).type.toLowerCase(); + const nonTextTypes = ['submit', 'reset', 'button', 'checkbox', 'radio', 'file', 'image']; + if (nonTextTypes.includes(type)) { + return false; + } + } + + // Check if element is disabled or readonly + return !element.disabled && !element.readOnly; + } + + return false; + } + + private isEditingContent(target: Element | null): boolean { + if (!target) return false; + + // Check if element or any parent has contenteditable + let element = target as Element; + while (element) { + if (element.getAttribute && element.getAttribute('contenteditable') === 'true') { + return true; + } + element = element.parentElement as Element; + + // Stop at body to avoid infinite loop + if (!element || element.tagName.toLowerCase() === 'body') { + break; + } + } + + return false; + } + + private isTargetMatch(target: Element | null, selector: string): boolean { + if (!target) return false; + + try { + return target.matches(selector) || !!target.closest(selector); + } catch (error) { + console.warn('Invalid CSS selector in keyboard shortcut:', selector, error); + return false; + } + } + + // Public API methods + addShortcut(shortcut: KeyboardShortcut): void { + this.shortcuts = [...this.shortcuts, shortcut]; + } + + removeShortcut(shortcut: KeyboardShortcut): void { + this.shortcuts = this.shortcuts.filter(s => + s.key !== shortcut.key || + s.ctrlKey !== shortcut.ctrlKey || + s.metaKey !== shortcut.metaKey || + s.altKey !== shortcut.altKey || + s.shiftKey !== shortcut.shiftKey + ); + } + + clearShortcuts(): void { + this.shortcuts = []; + } + + enable(): void { + if (!this.enabled) { + this.enabled = true; + this.document.addEventListener('keydown', this.boundKeydownHandler, true); + } + } + + disable(): void { + if (this.enabled) { + this.enabled = false; + this.document.removeEventListener('keydown', this.boundKeydownHandler, true); + } + } +} + +// Helper function to create common shortcuts +export function createShortcuts() { + return { + // Command palette + commandPalette: (): KeyboardShortcut => ({ + key: 'k', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: true + }), + + // Quick actions + quickSearch: (): KeyboardShortcut => ({ + key: '/', + preventDefault: true + }), + + // Navigation + goHome: (): KeyboardShortcut => ({ + key: 'h', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: true + }), + + goBack: (): KeyboardShortcut => ({ + key: '[', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: true + }), + + goForward: (): KeyboardShortcut => ({ + key: ']', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: true + }), + + // Common actions + save: (): KeyboardShortcut => ({ + key: 's', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: true + }), + + copy: (): KeyboardShortcut => ({ + key: 'c', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: false // Let browser handle copy + }), + + paste: (): KeyboardShortcut => ({ + key: 'v', + ctrlKey: !isMac(), + metaKey: isMac(), + preventDefault: false // Let browser handle paste + }), + + // Custom shortcut builder + create: (key: string, modifiers: { + ctrl?: boolean; + meta?: boolean; + alt?: boolean; + shift?: boolean; + preventDefault?: boolean; + target?: string; + } = {}): KeyboardShortcut => ({ + key, + ctrlKey: modifiers.ctrl, + metaKey: modifiers.meta, + altKey: modifiers.alt, + shiftKey: modifiers.shift, + preventDefault: modifiers.preventDefault ?? true, + target: modifiers.target + }) + }; +} + +// Platform detection helper +function isMac(): boolean { + return typeof navigator !== 'undefined' && navigator.platform.toUpperCase().indexOf('MAC') >= 0; +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/command-palette/index.ts b/projects/ui-essentials/src/lib/components/overlays/command-palette/index.ts new file mode 100644 index 0000000..3f9d4c2 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/overlays/command-palette/index.ts @@ -0,0 +1,5 @@ +export * from './command-palette.types'; +export * from './command-palette.service'; +export * from './command-palette.component'; +export * from './command-palette-item.component'; +export * from './global-keyboard.directive'; \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.scss b/projects/ui-essentials/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.scss new file mode 100644 index 0000000..44591e0 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.scss @@ -0,0 +1,364 @@ +@use '../../../../../../shared-ui/src/styles/semantic' as *; + +.ui-floating-toolbar { + // Core Structure + position: absolute; + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + opacity: 0; + transform: translateY(-8px); + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + z-index: $semantic-z-index-overlay; + pointer-events: none; + will-change: opacity, transform; + + // Visual Design + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-radius-lg; + box-shadow: $semantic-shadow-elevation-3; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + // Size Variants + &--sm { + padding: $semantic-spacing-component-xs; + gap: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + &--md { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-sm; + } + + &--lg { + padding: $semantic-spacing-component-md; + gap: $semantic-spacing-component-md; + font-family: map-get($semantic-typography-body-large, font-family); + font-size: map-get($semantic-typography-body-large, font-size); + font-weight: map-get($semantic-typography-body-large, font-weight); + line-height: map-get($semantic-typography-body-large, line-height); + } + + // Variant Styles + &--default { + background: $semantic-color-surface-primary; + border-color: $semantic-color-border-primary; + box-shadow: $semantic-shadow-elevation-2; + } + + &--elevated { + background: $semantic-color-surface-elevated; + border: none; + box-shadow: $semantic-shadow-elevation-4; + } + + &--floating { + background: $semantic-color-surface-secondary; + border-color: $semantic-color-border-subtle; + backdrop-filter: blur(8px); + box-shadow: $semantic-shadow-elevation-3; + } + + &--compact { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + } + + // Context Variants + &--contextual { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border: none; + } + + // State Variants + &--visible { + opacity: 1; + transform: translateY(0); + pointer-events: auto; + } + + &--entering { + opacity: 0; + transform: translateY(-8px); + } + + &--leaving { + opacity: 0; + transform: translateY(-8px); + transition-duration: $semantic-motion-duration-fast; + } + + &--disabled { + opacity: $semantic-opacity-disabled; + pointer-events: none; + } + + // Content Areas + &__actions { + display: flex; + align-items: center; + gap: $semantic-spacing-component-xs; + } + + &__action { + display: inline-flex; + align-items: center; + justify-content: center; + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + background: transparent; + border: none; + border-radius: $semantic-border-button-radius; + color: $semantic-color-text-primary; + font-family: map-get($semantic-typography-button-small, font-family); + font-size: map-get($semantic-typography-button-small, font-size); + font-weight: map-get($semantic-typography-button-small, font-weight); + line-height: map-get($semantic-typography-button-small, line-height); + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + min-height: $semantic-sizing-button-height-sm; + white-space: nowrap; + user-select: none; + + &:hover:not(:disabled) { + background: $semantic-color-surface-elevated; + color: $semantic-color-text-primary; + } + + &:active:not(:disabled) { + background: $semantic-color-surface-pressed; + transform: translateY(1px); + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &--disabled, + &:disabled { + opacity: $semantic-opacity-disabled; + cursor: not-allowed; + pointer-events: none; + } + + // Contextual toolbar button styles + .ui-floating-toolbar--contextual & { + color: $semantic-color-on-primary; + + &:hover:not(:disabled) { + background: rgba(255, 255, 255, 0.1); + color: $semantic-color-on-primary; + } + + &:active:not(:disabled) { + background: rgba(255, 255, 255, 0.2); + } + } + + // Icon styling + i { + flex-shrink: 0; + font-size: $semantic-typography-font-size-sm; + line-height: 1; + } + } + + &__action-label { + display: none; + + &--visible { + display: inline; + } + + // Show labels on small screens or when no icon + @media (max-width: $semantic-breakpoint-sm - 1) { + display: inline; + } + } + + &__shortcut { + display: inline-flex; + align-items: center; + padding: 2px 6px; + background: $semantic-color-surface-secondary; + color: $semantic-color-text-secondary; + font-family: $semantic-typography-font-family-mono; + font-size: $semantic-typography-font-size-xs; + font-weight: $semantic-typography-font-weight-medium; + border-radius: $semantic-border-radius-sm; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + margin-left: $semantic-spacing-component-xs; + + .ui-floating-toolbar--contextual & { + background: rgba(255, 255, 255, 0.15); + color: $semantic-color-on-primary; + border-color: rgba(255, 255, 255, 0.2); + opacity: $semantic-opacity-subtle; + } + } + + &__divider { + width: $semantic-border-width-1; + height: $semantic-sizing-button-height-sm; + background: $semantic-color-border-subtle; + margin: 0 $semantic-spacing-component-xs; + + .ui-floating-toolbar--contextual & { + background: rgba(255, 255, 255, 0.2); + } + } + + &__label { + color: $semantic-color-text-secondary; + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + white-space: nowrap; + + .ui-floating-toolbar--contextual & { + color: $semantic-color-on-primary; + opacity: $semantic-opacity-subtle; + } + } + + // Position Variants + &--top { + transform: translateY(8px); + + &.ui-floating-toolbar--visible { + transform: translateY(0); + } + } + + &--bottom { + transform: translateY(-8px); + + &.ui-floating-toolbar--visible { + transform: translateY(0); + } + } + + &--left { + transform: translateX(8px); + + &.ui-floating-toolbar--visible { + transform: translateX(0); + } + } + + &--right { + transform: translateX(-8px); + + &.ui-floating-toolbar--visible { + transform: translateX(0); + } + } + + // Animation Variants + &--slide-fade { + transition: opacity $semantic-motion-duration-normal $semantic-motion-easing-ease, + transform $semantic-motion-duration-normal $semantic-motion-easing-ease; + } + + &--bounce { + transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-spring, + transform $semantic-motion-duration-fast $semantic-motion-easing-spring; + } + + // Interactive States + &:not(.ui-floating-toolbar--disabled) { + &:focus-within { + box-shadow: $semantic-shadow-elevation-4, 0 0 0 2px $semantic-color-focus; + } + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-md - 1) { + padding: $semantic-spacing-component-xs $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + + &.ui-floating-toolbar--lg { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-sm; + } + } + + @media (max-width: $semantic-breakpoint-sm - 1) { + padding: $semantic-spacing-component-xs; + gap: $semantic-spacing-component-xs; + border-radius: $semantic-border-radius-md; + + font-family: map-get($semantic-typography-body-small, font-family); + font-size: map-get($semantic-typography-body-small, font-size); + font-weight: map-get($semantic-typography-body-small, font-weight); + line-height: map-get($semantic-typography-body-small, line-height); + + &__actions { + gap: $semantic-spacing-component-xs; + } + + &__divider { + margin: 0 $semantic-spacing-component-xs; + } + } + + // Touch-friendly adjustments + @media (hover: none) and (pointer: coarse) { + min-height: $semantic-sizing-touch-target; + + &__actions { + min-height: $semantic-sizing-touch-minimum; + } + } + + // High contrast mode support + @media (prefers-contrast: high) { + border-width: $semantic-border-width-2; + box-shadow: none; + + &--elevated, + &--floating { + border: $semantic-border-width-2 solid $semantic-color-border-primary; + } + } + + // Reduced motion support + @media (prefers-reduced-motion: reduce) { + transition: none; + + &--slide-fade, + &--bounce { + transition: none; + } + } +} + +// Backdrop for modal-like behavior when needed +.ui-floating-toolbar-backdrop { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background: transparent; + z-index: calc($semantic-z-index-overlay - 1); + pointer-events: auto; +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.ts b/projects/ui-essentials/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.ts new file mode 100644 index 0000000..b5966f6 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/overlays/floating-toolbar/floating-toolbar.component.ts @@ -0,0 +1,889 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnDestroy, AfterViewInit, inject, ElementRef, Renderer2, ViewChild, HostListener, TemplateRef } from '@angular/core'; +import { CommonModule, DOCUMENT } from '@angular/common'; +import { signal, computed } from '@angular/core'; + +type ToolbarSize = 'sm' | 'md' | 'lg'; +type ToolbarVariant = 'default' | 'elevated' | 'floating' | 'compact' | 'contextual'; +type ToolbarPosition = 'top' | 'bottom' | 'left' | 'right'; +type ToolbarAnimationType = 'slide-fade' | 'bounce' | 'none'; + +export interface ToolbarAction { + id: string; + label: string; + icon?: string; + iconTemplate?: TemplateRef; + disabled?: boolean; + visible?: boolean; + divider?: boolean; + tooltip?: string; + shortcut?: string; + callback?: (action: ToolbarAction, event: Event) => void; +} + +export interface ToolbarContext { + type?: string; + selection?: any; + data?: any; + metadata?: Record; +} + +export interface ToolbarPositionData { + top: number; + left: number; + position: ToolbarPosition; +} + +export interface FloatingToolbarConfig { + size?: ToolbarSize; + variant?: ToolbarVariant; + position?: ToolbarPosition; + autoPosition?: boolean; + visible?: boolean; + actions?: ToolbarAction[]; + context?: ToolbarContext; + trigger?: 'manual' | 'selection' | 'hover' | 'contextmenu'; + autoHide?: boolean; + hideDelay?: number; + showAnimation?: ToolbarAnimationType; + backdrop?: boolean; + backdropClosable?: boolean; + escapeClosable?: boolean; + offset?: number; + label?: string; + showLabel?: boolean; + showShortcuts?: boolean; +} + +@Component({ + selector: 'ui-floating-toolbar', + standalone: true, + imports: [CommonModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` + @if (isVisible()) { + + @if (backdrop) { +
+ } + + +
+ + @if (label && showLabel) { +
{{ label }}
+
+ } + + +
+ @for (action of visibleActions(); track action.id) { + + @if (action.divider) { +
+ } + + + + } +
+ + + +
+ } + `, + styleUrl: './floating-toolbar.component.scss' +}) +export class FloatingToolbarComponent implements OnInit, OnDestroy, AfterViewInit { + // Dependencies + private elementRef = inject(ElementRef); + private renderer = inject(Renderer2); + private document = inject(DOCUMENT); + + // ViewChild references + @ViewChild('toolbarElement', { static: false }) toolbarElement?: ElementRef; + + // Core Inputs + @Input() size: ToolbarSize = 'md'; + @Input() variant: ToolbarVariant = 'default'; + @Input() position: ToolbarPosition = 'top'; + @Input() autoPosition = true; + @Input() disabled = false; + @Input() label = ''; + @Input() showLabel = false; + @Input() showShortcuts = false; + + // Behavior Inputs + @Input() trigger: 'manual' | 'selection' | 'hover' | 'contextmenu' = 'manual'; + @Input() autoHide = false; + @Input() hideDelay = 3000; + @Input() showAnimation: ToolbarAnimationType = 'slide-fade'; + @Input() backdrop = false; + @Input() backdropClosable = true; + @Input() escapeClosable = true; + @Input() offset = 12; + + // Context and Actions + @Input() actions: ToolbarAction[] = []; + @Input() context?: ToolbarContext; + + // Reference element (anchor point) + @Input() anchorElement?: HTMLElement; + @Input() anchorSelector?: string; + + // State Input + @Input() set visible(value: boolean) { + if (value !== this._visible()) { + this._visible.set(value); + if (value) { + this.show(); + } else { + this.hide(); + } + } + } + + get visible(): boolean { + return this._visible(); + } + + // Outputs + @Output() visibleChange = new EventEmitter(); + @Output() shown = new EventEmitter(); + @Output() hidden = new EventEmitter(); + @Output() actionClicked = new EventEmitter<{ action: ToolbarAction; event: Event }>(); + @Output() contextChanged = new EventEmitter(); + @Output() positionChanged = new EventEmitter(); + @Output() backdropClicked = new EventEmitter(); + @Output() escapePressed = new EventEmitter(); + + // Internal state signals + private _visible = signal(false); + private _entering = signal(false); + private _leaving = signal(false); + private _computedPosition = signal(this.position); + private _positionData = signal({ top: 0, left: 0, position: this.position }); + private _focusedActionIndex = signal(-1); + + // Computed signals + readonly isVisible = computed(() => this._visible() || this._entering() || this._leaving()); + readonly isEntering = computed(() => this._entering()); + readonly isLeaving = computed(() => this._leaving()); + readonly computedPosition = computed(() => this._computedPosition()); + readonly positionData = computed(() => this._positionData()); + readonly visibleActions = computed(() => + this.actions.filter(action => action.visible !== false) + ); + readonly focusedActionIndex = computed(() => this._focusedActionIndex()); + + // Internal properties + private hideTimer?: number; + private showTimer?: number; + private resizeObserver?: ResizeObserver; + private selectionObserver?: MutationObserver; + private currentSelection?: Selection | null; + private animationDuration = 200; // ms + + // Unique ID for accessibility + readonly toolbarId = `floating-toolbar-${Math.random().toString(36).substr(2, 9)}`; + + ngOnInit(): void { + if (this.visible) { + this.show(); + } + this.setupTriggerListeners(); + this.setupAnchorElement(); + } + + ngAfterViewInit(): void { + if (this.visible) { + setTimeout(() => this.updatePosition(), 0); + } + } + + ngOnDestroy(): void { + this.clearTimers(); + this.cleanup(); + } + + /** + * Shows the floating toolbar with animation and positioning + */ + show(): void { + if (this._visible() || this.disabled) return; + + console.log('FloatingToolbar: show() called'); + this.clearTimers(); + + this._visible.set(true); + this._entering.set(true); + this.visibleChange.emit(true); + console.log('FloatingToolbar: visibility signals updated', { + visible: this._visible(), + entering: this._entering(), + isVisible: this.isVisible() + }); + + this.updatePosition(); + this.setupPositionObservers(); + + // Animation timing + setTimeout(() => { + this._entering.set(false); + this.shown.emit(); + console.log('FloatingToolbar: animation completed'); + + // Auto-hide if enabled + if (this.autoHide && this.hideDelay > 0) { + this.scheduleAutoHide(); + } + }, this.animationDuration); + } + + /** + * Hides the floating toolbar with animation + */ + hide(): void { + if (!this._visible()) return; + + this.clearTimers(); + this.cleanup(); + + this._leaving.set(true); + + // Animation timing + setTimeout(() => { + this._visible.set(false); + this._leaving.set(false); + this.visibleChange.emit(false); + this.hidden.emit(); + }, this.animationDuration); + } + + /** + * Toggles toolbar visibility + */ + toggle(): void { + if (this._visible()) { + this.hide(); + } else { + this.show(); + } + } + + /** + * Updates toolbar actions based on context + */ + updateContext(context: ToolbarContext): void { + this.context = context; + this.contextChanged.emit(context); + + // Update action visibility and state based on context + this.updateActionsFromContext(); + } + + /** + * Adds an action to the toolbar + */ + addAction(action: ToolbarAction, index?: number): void { + if (index !== undefined && index >= 0 && index < this.actions.length) { + this.actions.splice(index, 0, action); + } else { + this.actions.push(action); + } + } + + /** + * Removes an action from the toolbar + */ + removeAction(actionId: string): void { + const index = this.actions.findIndex(action => action.id === actionId); + if (index !== -1) { + this.actions.splice(index, 1); + } + } + + /** + * Updates an existing action + */ + updateAction(actionId: string, updates: Partial): void { + const action = this.actions.find(a => a.id === actionId); + if (action) { + Object.assign(action, updates); + } + } + + /** + * Updates the toolbar position relative to the anchor element + */ + updatePosition(): void { + if (!this.anchorElement || !this.toolbarElement) { + console.log('FloatingToolbar: updatePosition() - missing elements', { + anchorElement: !!this.anchorElement, + toolbarElement: !!this.toolbarElement + }); + return; + } + + const anchorRect = this.anchorElement.getBoundingClientRect(); + const toolbarEl = this.toolbarElement.nativeElement; + const toolbarRect = toolbarEl.getBoundingClientRect(); + const viewportWidth = window.innerWidth; + const viewportHeight = window.innerHeight; + const scrollLeft = window.pageXOffset || document.documentElement.scrollLeft; + const scrollTop = window.pageYOffset || document.documentElement.scrollTop; + + let position = this.position; + let top = 0; + let left = 0; + + // Calculate initial position + const positions = this.calculatePositions(anchorRect, toolbarRect, scrollLeft, scrollTop); + const initialPos = positions[position]; + top = initialPos.top; + left = initialPos.left; + + // Auto-position if enabled and toolbar goes outside viewport + if (this.autoPosition) { + const bestPosition = this.findBestPosition(positions, viewportWidth, viewportHeight, toolbarRect); + position = bestPosition.position; + top = bestPosition.top; + left = bestPosition.left; + } + + // Ensure toolbar stays within viewport bounds + const finalPosition = this.constrainToViewport( + { top, left, position }, + toolbarRect, + viewportWidth, + viewportHeight + ); + + // Update state + this._computedPosition.set(finalPosition.position); + this._positionData.set(finalPosition); + + // Emit position change if it changed + if (finalPosition.position !== this.position) { + this.positionChanged.emit(finalPosition.position); + } + } + + /** + * Handles action button clicks + */ + handleActionClick(action: ToolbarAction, event: Event): void { + if (action.disabled) return; + + event.stopPropagation(); + + // Execute action callback if provided + if (action.callback) { + action.callback(action, event); + } + + // Emit action clicked event + this.actionClicked.emit({ action, event }); + + // Focus management + const button = event.target as HTMLButtonElement; + if (button) { + const actionIndex = this.visibleActions().findIndex(a => a.id === action.id); + this._focusedActionIndex.set(actionIndex); + } + } + + /** + * Handles keyboard navigation within actions + */ + handleActionKeydown(action: ToolbarAction, event: KeyboardEvent): void { + const visibleActions = this.visibleActions(); + const currentIndex = this._focusedActionIndex(); + + switch (event.key) { + case 'ArrowLeft': + case 'ArrowUp': + event.preventDefault(); + this.focusPreviousAction(); + break; + + case 'ArrowRight': + case 'ArrowDown': + event.preventDefault(); + this.focusNextAction(); + break; + + case 'Home': + event.preventDefault(); + this.focusFirstAction(); + break; + + case 'End': + event.preventDefault(); + this.focusLastAction(); + break; + + case 'Enter': + case ' ': + event.preventDefault(); + this.handleActionClick(action, event); + break; + } + } + + /** + * Handles toolbar-level keyboard events + */ + handleKeydown(event: KeyboardEvent): void { + switch (event.key) { + case 'Escape': + if (this.escapeClosable) { + event.preventDefault(); + this.hide(); + this.escapePressed.emit(event); + } + break; + + case 'Tab': + // Allow normal tab behavior within toolbar + break; + + default: + // Handle keyboard shortcuts + this.handleKeyboardShortcut(event); + break; + } + } + + /** + * Handles backdrop clicks + */ + handleBackdropClick(event: MouseEvent): void { + if (this.backdropClosable) { + this.hide(); + this.backdropClicked.emit(event); + } + } + + /** + * Document click listener to handle outside clicks + */ + @HostListener('document:click', ['$event']) + onDocumentClick(event: MouseEvent): void { + if (!this._visible()) return; + + const target = event.target as HTMLElement; + const isInsideToolbar = this.elementRef.nativeElement.contains(target); + const isInsideAnchor = this.anchorElement?.contains(target); + + if (!isInsideToolbar && !isInsideAnchor && this.trigger !== 'manual') { + this.hide(); + } + } + + /** + * Document selection change listener for text selection trigger + */ + @HostListener('document:selectionchange', ['$event']) + onDocumentSelectionChange(event: Event): void { + if (this.trigger !== 'selection') return; + + const selection = window.getSelection(); + + if (selection && selection.toString().trim()) { + // Text is selected + this.currentSelection = selection; + this.updateSelectionAnchor(); + this.show(); + } else { + // No text selected + this.currentSelection = null; + if (this.autoHide) { + this.scheduleAutoHide(); + } + } + } + + /** + * Focus management methods + */ + private focusFirstAction(): void { + const actions = this.visibleActions(); + if (actions.length > 0) { + this._focusedActionIndex.set(0); + this.focusActionButton(0); + } + } + + private focusLastAction(): void { + const actions = this.visibleActions(); + if (actions.length > 0) { + const lastIndex = actions.length - 1; + this._focusedActionIndex.set(lastIndex); + this.focusActionButton(lastIndex); + } + } + + private focusNextAction(): void { + const actions = this.visibleActions(); + const currentIndex = this._focusedActionIndex(); + const nextIndex = (currentIndex + 1) % actions.length; + this._focusedActionIndex.set(nextIndex); + this.focusActionButton(nextIndex); + } + + private focusPreviousAction(): void { + const actions = this.visibleActions(); + const currentIndex = this._focusedActionIndex(); + const prevIndex = currentIndex <= 0 ? actions.length - 1 : currentIndex - 1; + this._focusedActionIndex.set(prevIndex); + this.focusActionButton(prevIndex); + } + + private focusActionButton(index: number): void { + if (!this.toolbarElement) return; + + const buttons = this.toolbarElement.nativeElement.querySelectorAll('.ui-floating-toolbar__action'); + const button = buttons[index] as HTMLButtonElement; + if (button && !button.disabled) { + button.focus(); + } + } + + /** + * Handles keyboard shortcuts + */ + private handleKeyboardShortcut(event: KeyboardEvent): void { + const shortcutKey = this.getShortcutString(event); + const action = this.actions.find(a => a.shortcut === shortcutKey && !a.disabled && a.visible !== false); + + if (action) { + event.preventDefault(); + this.handleActionClick(action, event); + } + } + + private getShortcutString(event: KeyboardEvent): string { + const parts: string[] = []; + + if (event.ctrlKey) parts.push('Ctrl'); + if (event.altKey) parts.push('Alt'); + if (event.shiftKey) parts.push('Shift'); + if (event.metaKey) parts.push('Cmd'); + + if (event.key && event.key !== 'Control' && event.key !== 'Alt' && event.key !== 'Shift' && event.key !== 'Meta') { + parts.push(event.key); + } + + return parts.join('+'); + } + + /** + * Sets up trigger listeners based on trigger type + */ + private setupTriggerListeners(): void { + switch (this.trigger) { + case 'hover': + if (this.anchorElement) { + this.renderer.listen(this.anchorElement, 'mouseenter', () => this.show()); + this.renderer.listen(this.anchorElement, 'mouseleave', () => { + if (this.autoHide) { + this.scheduleAutoHide(); + } + }); + + // Keep toolbar open when hovering over it + this.renderer.listen(this.elementRef.nativeElement, 'mouseenter', () => { + this.clearTimers(); + }); + this.renderer.listen(this.elementRef.nativeElement, 'mouseleave', () => { + if (this.autoHide) { + this.scheduleAutoHide(); + } + }); + } + break; + + case 'contextmenu': + if (this.anchorElement) { + this.renderer.listen(this.anchorElement, 'contextmenu', (event: MouseEvent) => { + event.preventDefault(); + this.updatePositionFromEvent(event); + this.show(); + }); + } + break; + + case 'selection': + // Handled by document listener + break; + + case 'manual': + default: + // No automatic triggers + break; + } + } + + private setupAnchorElement(): void { + if (this.anchorSelector && !this.anchorElement) { + const element = this.document.querySelector(this.anchorSelector) as HTMLElement; + if (element) { + this.anchorElement = element; + this.setupTriggerListeners(); + } + } + } + + private updateSelectionAnchor(): void { + if (!this.currentSelection) return; + + const range = this.currentSelection.getRangeAt(0); + const rect = range.getBoundingClientRect(); + + // Create a temporary anchor element for positioning + if (rect.width > 0 && rect.height > 0) { + this.anchorElement = { + getBoundingClientRect: () => rect, + contains: () => false + } as unknown as HTMLElement; + } + } + + private updatePositionFromEvent(event: MouseEvent): void { + // Create a temporary anchor element at the event position + this.anchorElement = { + getBoundingClientRect: () => ({ + top: event.clientY, + left: event.clientX, + right: event.clientX, + bottom: event.clientY, + width: 0, + height: 0 + } as DOMRect), + contains: () => false + } as unknown as HTMLElement; + } + + private updateActionsFromContext(): void { + if (!this.context) return; + + // Update action visibility and state based on context + this.actions.forEach(action => { + // This could be enhanced with more sophisticated context-aware logic + if (this.context?.type && action.id.includes(this.context.type)) { + action.visible = true; + } + }); + } + + private scheduleAutoHide(): void { + this.clearTimers(); + this.hideTimer = window.setTimeout(() => { + this.hide(); + }, this.hideDelay); + } + + private clearTimers(): void { + if (this.hideTimer) { + clearTimeout(this.hideTimer); + this.hideTimer = undefined; + } + if (this.showTimer) { + clearTimeout(this.showTimer); + this.showTimer = undefined; + } + } + + private setupPositionObservers(): void { + // Resize observer for position updates + this.resizeObserver = new ResizeObserver(() => { + if (this._visible()) { + this.updatePosition(); + } + }); + this.resizeObserver.observe(document.body); + + // Listen for window scroll and resize + window.addEventListener('scroll', this.updatePositionThrottled, { passive: true }); + window.addEventListener('resize', this.updatePositionThrottled, { passive: true }); + } + + private updatePositionThrottled = this.throttle(() => { + if (this._visible()) { + this.updatePosition(); + } + }, 16); // ~60fps + + private cleanup(): void { + this.resizeObserver?.disconnect(); + this.selectionObserver?.disconnect(); + window.removeEventListener('scroll', this.updatePositionThrottled); + window.removeEventListener('resize', this.updatePositionThrottled); + } + + private calculatePositions( + anchorRect: DOMRect, + toolbarRect: DOMRect, + scrollLeft: number, + scrollTop: number + ): Record { + const anchorCenterX = anchorRect.left + anchorRect.width / 2; + const anchorCenterY = anchorRect.top + anchorRect.height / 2; + + return { + 'top': { + top: anchorRect.top - toolbarRect.height - this.offset + scrollTop, + left: anchorCenterX - toolbarRect.width / 2 + scrollLeft, + position: 'top' + }, + 'bottom': { + top: anchorRect.bottom + this.offset + scrollTop, + left: anchorCenterX - toolbarRect.width / 2 + scrollLeft, + position: 'bottom' + }, + 'left': { + top: anchorCenterY - toolbarRect.height / 2 + scrollTop, + left: anchorRect.left - toolbarRect.width - this.offset + scrollLeft, + position: 'left' + }, + 'right': { + top: anchorCenterY - toolbarRect.height / 2 + scrollTop, + left: anchorRect.right + this.offset + scrollLeft, + position: 'right' + } + }; + } + + private findBestPosition( + positions: Record, + viewportWidth: number, + viewportHeight: number, + toolbarRect: DOMRect + ): ToolbarPositionData { + const preferenceOrder: ToolbarPosition[] = [ + this.position, + ...Object.keys(positions).filter(p => p !== this.position) as ToolbarPosition[] + ]; + + for (const pos of preferenceOrder) { + const posData = positions[pos]; + const fitsHorizontally = posData.left >= 0 && posData.left + toolbarRect.width <= viewportWidth; + const fitsVertically = posData.top >= 0 && posData.top + toolbarRect.height <= viewportHeight; + + if (fitsHorizontally && fitsVertically) { + return posData; + } + } + + return positions[this.position]; + } + + private constrainToViewport( + position: ToolbarPositionData, + toolbarRect: DOMRect, + viewportWidth: number, + viewportHeight: number + ): ToolbarPositionData { + const margin = 8; + let { top, left } = position; + + // Constrain horizontally + if (left < margin) { + left = margin; + } else if (left + toolbarRect.width > viewportWidth - margin) { + left = viewportWidth - toolbarRect.width - margin; + } + + // Constrain vertically + if (top < margin) { + top = margin; + } else if (top + toolbarRect.height > viewportHeight - margin) { + top = viewportHeight - toolbarRect.height - margin; + } + + return { ...position, top, left }; + } + + private throttle void>(func: T, limit: number): T { + let inThrottle: boolean; + return ((...args: any[]) => { + if (!inThrottle) { + func.apply(this, args); + inThrottle = true; + setTimeout(() => inThrottle = false, limit); + } + }) as T; + } + + /** + * Public API method to apply configuration + */ + configure(config: FloatingToolbarConfig): void { + Object.assign(this, config); + } + + /** + * Public API method to set anchor element + */ + setAnchorElement(element: HTMLElement): void { + this.anchorElement = element; + this.setupTriggerListeners(); + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/floating-toolbar/index.ts b/projects/ui-essentials/src/lib/components/overlays/floating-toolbar/index.ts new file mode 100644 index 0000000..8429786 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/overlays/floating-toolbar/index.ts @@ -0,0 +1 @@ +export * from './floating-toolbar.component'; \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/overlays/index.ts b/projects/ui-essentials/src/lib/components/overlays/index.ts index aadefd4..cece9f2 100644 --- a/projects/ui-essentials/src/lib/components/overlays/index.ts +++ b/projects/ui-essentials/src/lib/components/overlays/index.ts @@ -2,4 +2,6 @@ export * from './modal'; export * from './drawer'; export * from './backdrop'; export * from './overlay-container'; -export * from './popover'; \ No newline at end of file +export * from './popover'; +export * from './command-palette'; +export * from './floating-toolbar'; \ No newline at end of file