Add comprehensive UI component expansion

- Add new components: enhanced-table, fab-menu, icon-button, snackbar, select, tag-input, bottom-navigation, stepper, command-palette, floating-toolbar, transfer-list
- Refactor table-actions and select components
- Update component styles and exports
- Add corresponding demo components

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
skyai_dev
2025-09-03 21:50:45 +10:00
parent e9f975eb02
commit 2bbbf1b9f1
87 changed files with 15454 additions and 206 deletions

View File

@@ -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%;
}
}

View File

@@ -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: `
<div class="demo-container">
<h2>Bottom Navigation Demo</h2>
<!-- Size Variants -->
<section class="demo-section">
<h3>Sizes</h3>
<div class="demo-row demo-row--vertical">
@for (size of sizes; track size) {
<div class="demo-item">
<h4>{{ size.toUpperCase() }} Size</h4>
<div class="demo-bottom-nav-container">
<ui-bottom-navigation
[size]="size"
[items]="basicItems"
[activeItemId]="activeItemIds[size]"
(activeItemChanged)="handleActiveItemChange(size, $event)"
(itemClicked)="handleItemClick($event)">
<fa-icon [icon]="faHome" data-icon="home"></fa-icon>
<fa-icon [icon]="faSearch" data-icon="search"></fa-icon>
<fa-icon [icon]="faUser" data-icon="profile"></fa-icon>
<fa-icon [icon]="faCog" data-icon="settings"></fa-icon>
</ui-bottom-navigation>
</div>
</div>
}
</div>
</section>
<!-- Variant Styles -->
<section class="demo-section">
<h3>Variants</h3>
<div class="demo-row demo-row--vertical">
@for (variant of variants; track variant) {
<div class="demo-item">
<h4>{{ variant.charAt(0).toUpperCase() + variant.slice(1) }} Variant</h4>
<div class="demo-bottom-nav-container">
<ui-bottom-navigation
[variant]="variant"
[items]="basicItems"
[activeItemId]="activeItemIds[variant]"
(activeItemChanged)="handleActiveItemChange(variant, $event)"
(itemClicked)="handleItemClick($event)">
<fa-icon [icon]="faHome" data-icon="home"></fa-icon>
<fa-icon [icon]="faSearch" data-icon="search"></fa-icon>
<fa-icon [icon]="faUser" data-icon="profile"></fa-icon>
<fa-icon [icon]="faCog" data-icon="settings"></fa-icon>
</ui-bottom-navigation>
</div>
</div>
}
</div>
</section>
<!-- With Badges -->
<section class="demo-section">
<h3>With Badges</h3>
<div class="demo-bottom-nav-container">
<ui-bottom-navigation
[items]="itemsWithBadges"
[activeItemId]="badgeActiveItemId"
(activeItemChanged)="badgeActiveItemId = $event"
(itemClicked)="handleItemClick($event)">
<fa-icon [icon]="faHome" data-icon="home"></fa-icon>
<fa-icon [icon]="faShoppingCart" data-icon="cart"></fa-icon>
<fa-icon [icon]="faBell" data-icon="notifications"></fa-icon>
<fa-icon [icon]="faBookmark" data-icon="saved"></fa-icon>
<fa-icon [icon]="faUser" data-icon="profile"></fa-icon>
</ui-bottom-navigation>
</div>
</section>
<!-- States -->
<section class="demo-section">
<h3>States</h3>
<div class="demo-row demo-row--vertical">
<div class="demo-item">
<h4>Elevated</h4>
<div class="demo-bottom-nav-container">
<ui-bottom-navigation
[items]="basicItems"
[elevated]="true"
[activeItemId]="elevatedActiveItemId"
(activeItemChanged)="elevatedActiveItemId = $event"
(itemClicked)="handleItemClick($event)">
<fa-icon [icon]="faHome" data-icon="home"></fa-icon>
<fa-icon [icon]="faSearch" data-icon="search"></fa-icon>
<fa-icon [icon]="faUser" data-icon="profile"></fa-icon>
<fa-icon [icon]="faCog" data-icon="settings"></fa-icon>
</ui-bottom-navigation>
</div>
</div>
<div class="demo-item">
<h4>With Disabled Item</h4>
<div class="demo-bottom-nav-container">
<ui-bottom-navigation
[items]="itemsWithDisabled"
[activeItemId]="disabledActiveItemId"
(activeItemChanged)="disabledActiveItemId = $event"
(itemClicked)="handleItemClick($event)">
<fa-icon [icon]="faHome" data-icon="home"></fa-icon>
<fa-icon [icon]="faSearch" data-icon="search"></fa-icon>
<fa-icon [icon]="faUser" data-icon="profile"></fa-icon>
<fa-icon [icon]="faCog" data-icon="settings"></fa-icon>
</ui-bottom-navigation>
</div>
</div>
</div>
</section>
<!-- Interactive Example -->
<section class="demo-section">
<h3>Interactive Example</h3>
<div class="demo-bottom-nav-container">
<ui-bottom-navigation
[items]="interactiveItems"
[activeItemId]="interactiveActiveItemId"
(activeItemChanged)="handleInteractiveItemChange($event)"
(itemClicked)="handleInteractiveItemClick($event)">
<fa-icon [icon]="faHome" data-icon="home"></fa-icon>
<fa-icon [icon]="faSearch" data-icon="search"></fa-icon>
<fa-icon [icon]="faHeart" data-icon="favorites"></fa-icon>
<fa-icon [icon]="faUser" data-icon="profile"></fa-icon>
</ui-bottom-navigation>
</div>
<div class="demo-info">
<p><strong>Active Item:</strong> {{ getActiveItemLabel() }}</p>
<p><strong>Click Count:</strong> {{ clickCount }}</p>
<p><strong>Last Clicked:</strong> {{ lastClickedItem || 'None' }}</p>
</div>
<div class="demo-controls">
<button
class="demo-button"
(click)="toggleHidden()"
[class.active]="isHidden">
{{ isHidden ? 'Show' : 'Hide' }} Navigation
</button>
<button
class="demo-button"
(click)="incrementBadge()">
Increment Heart Badge
</button>
</div>
</section>
</div>
`,
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<string, string> = {
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++;
}
}

View File

@@ -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;
}
}

View File

@@ -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: `
<div class="demo-container" uiGlobalKeyboard [shortcuts]="globalShortcuts" (shortcutTriggered)="handleGlobalShortcut($event)">
<div class="demo-header">
<h2 class="demo-title">
<fa-icon [icon]="faPalette" class="demo-title-icon"></fa-icon>
Command Palette Demo
</h2>
<p class="demo-description">
A powerful command palette for quick navigation and actions. Press <kbd>Ctrl+K</kbd> (or <kbd>⌘K</kbd> on Mac) to open.
</p>
</div>
<!-- Quick Start Section -->
<section class="demo-section">
<h3 class="demo-section-title">Quick Start</h3>
<div class="demo-actions">
<button
class="demo-button demo-button--primary"
(click)="openCommandPalette()"
>
<fa-icon [icon]="faKeyboard"></fa-icon>
Open Command Palette
</button>
<button
class="demo-button demo-button--secondary"
(click)="registerSampleCommands()"
>
<fa-icon [icon]="faPlus"></fa-icon>
Add Sample Commands
</button>
<button
class="demo-button demo-button--outline"
(click)="clearCommands()"
>
Clear Commands
</button>
</div>
</section>
<!-- Configuration Section -->
<section class="demo-section">
<h3 class="demo-section-title">Configuration</h3>
<div class="demo-config-grid">
<div class="demo-config-item">
<label class="demo-label">
<input
type="checkbox"
[checked]="config.showCategories"
(change)="updateConfig('showCategories', $event)"
class="demo-checkbox"
/>
Show Categories
</label>
</div>
<div class="demo-config-item">
<label class="demo-label">
<input
type="checkbox"
[checked]="config.showShortcuts"
(change)="updateConfig('showShortcuts', $event)"
class="demo-checkbox"
/>
Show Shortcuts
</label>
</div>
<div class="demo-config-item">
<label class="demo-label">
<input
type="checkbox"
[checked]="config.showRecent"
(change)="updateConfig('showRecent', $event)"
class="demo-checkbox"
/>
Show Recent Commands
</label>
</div>
<div class="demo-config-item">
<label class="demo-label">
Max Results:
<input
type="number"
[value]="config.maxResults"
(input)="updateConfig('maxResults', $event)"
class="demo-input"
min="1"
max="50"
/>
</label>
</div>
<div class="demo-config-item">
<label class="demo-label">
Recent Limit:
<input
type="number"
[value]="config.recentLimit"
(input)="updateConfig('recentLimit', $event)"
class="demo-input"
min="1"
max="20"
/>
</label>
</div>
<div class="demo-config-item">
<label class="demo-label">
Size:
<select
[value]="paletteSize"
(change)="updatePaletteSize($event)"
class="demo-select"
>
<option value="md">Medium</option>
<option value="lg">Large</option>
<option value="xl">Extra Large</option>
</select>
</label>
</div>
</div>
</section>
<!-- Features Section -->
<section class="demo-section">
<h3 class="demo-section-title">Features</h3>
<div class="demo-features">
<div class="demo-feature">
<fa-icon [icon]="faKeyboard" class="demo-feature-icon"></fa-icon>
<h4>Keyboard Navigation</h4>
<p>Full keyboard support with arrow keys, Enter, and Escape</p>
</div>
<div class="demo-feature">
<fa-icon [icon]="faSearch" class="demo-feature-icon"></fa-icon>
<h4>Fuzzy Search</h4>
<p>Intelligent search with typo tolerance and keyword matching</p>
</div>
<div class="demo-feature">
<fa-icon [icon]="faRocket" class="demo-feature-icon"></fa-icon>
<h4>Quick Actions</h4>
<p>Execute commands instantly with global keyboard shortcuts</p>
</div>
<div class="demo-feature">
<fa-icon [icon]="faCog" class="demo-feature-icon"></fa-icon>
<h4>Categorization</h4>
<p>Organize commands by category for better discoverability</p>
</div>
<div class="demo-feature">
<fa-icon [icon]="faCopy" class="demo-feature-icon"></fa-icon>
<h4>Recent Commands</h4>
<p>Track and show recently used commands for quick access</p>
</div>
<div class="demo-feature">
<fa-icon [icon]="faEye" class="demo-feature-icon"></fa-icon>
<h4>Context Aware</h4>
<p>Commands can be shown or hidden based on application state</p>
</div>
</div>
</section>
<!-- Usage Statistics -->
<section class="demo-section">
<h3 class="demo-section-title">Usage Statistics</h3>
<div class="demo-stats">
<div class="demo-stat">
<div class="demo-stat-value">{{ registeredCommandsCount }}</div>
<div class="demo-stat-label">Registered Commands</div>
</div>
<div class="demo-stat">
<div class="demo-stat-value">{{ recentCommandsCount }}</div>
<div class="demo-stat-label">Recent Commands</div>
</div>
<div class="demo-stat">
<div class="demo-stat-value">{{ executionCount }}</div>
<div class="demo-stat-label">Commands Executed</div>
</div>
</div>
</section>
<!-- Keyboard Shortcuts Guide -->
<section class="demo-section">
<h3 class="demo-section-title">Keyboard Shortcuts</h3>
<div class="demo-shortcuts">
<div class="demo-shortcut">
<kbd class="demo-kbd">Ctrl</kbd> + <kbd class="demo-kbd">K</kbd>
<span>Open Command Palette</span>
</div>
<div class="demo-shortcut">
<kbd class="demo-kbd">↑</kbd> / <kbd class="demo-kbd">↓</kbd>
<span>Navigate Results</span>
</div>
<div class="demo-shortcut">
<kbd class="demo-kbd">Enter</kbd>
<span>Execute Selected Command</span>
</div>
<div class="demo-shortcut">
<kbd class="demo-kbd">Esc</kbd>
<span>Close Palette</span>
</div>
<div class="demo-shortcut">
<kbd class="demo-kbd">Tab</kbd>
<span>Navigate Results (Alternative)</span>
</div>
<div class="demo-shortcut">
<kbd class="demo-kbd">/</kbd>
<span>Quick Search</span>
</div>
</div>
</section>
<!-- Command Palette Component -->
<ui-command-palette
#commandPalette
[size]="paletteSize"
[showFooter]="true"
[autoFocus]="true"
(opened)="onPaletteOpened()"
(closed)="onPaletteClosed()"
(commandExecuted)="onCommandExecuted($event)"
/>
</div>
`,
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);
}
}

View File

@@ -78,12 +78,10 @@
margin: 0 auto; // Center the container margin: 0 auto; // Center the container
position: relative; // For proper positioning context position: relative; // For proper positioning context
// Override container component's auto margins that might be causing issues // Ensure proper centering
&.ui-container { &.ui-container {
margin-left: auto !important; margin-left: auto;
margin-right: auto !important; margin-right: auto;
left: auto !important;
right: auto !important;
} }
&.demo-container-tall { &.demo-container-tall {
@@ -187,13 +185,9 @@
// Additional container demo specific styles // Additional container demo specific styles
.demo-containers-showcase { .demo-containers-showcase {
// Force all containers to behave properly // Ensure containers behave properly
.ui-container { .ui-container {
// Ensure containers don't push beyond boundaries
position: relative; position: relative;
left: 0 !important;
right: 0 !important;
transform: none !important;
} }
// Size-specific constraints to prevent overflow // Size-specific constraints to prevent overflow

View File

@@ -59,6 +59,8 @@ import { SplitButtonDemoComponent } from './split-button-demo/split-button-demo.
import { CommandPaletteDemoComponent } from './command-palette-demo/command-palette-demo.component'; import { CommandPaletteDemoComponent } from './command-palette-demo/command-palette-demo.component';
import { TransferListDemoComponent } from './transfer-list-demo/transfer-list-demo.component'; import { TransferListDemoComponent } from './transfer-list-demo/transfer-list-demo.component';
import { FloatingToolbarDemoComponent } from './floating-toolbar-demo/floating-toolbar-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({ @Component({
@@ -87,6 +89,10 @@ import { FloatingToolbarDemoComponent } from './floating-toolbar-demo/floating-t
<ui-button-demo></ui-button-demo> <ui-button-demo></ui-button-demo>
} }
@case ("icon-button") {
<ui-icon-button-demo></ui-icon-button-demo>
}
@case ("cards") { @case ("cards") {
<ui-card-demo></ui-card-demo> <ui-card-demo></ui-card-demo>
} }
@@ -293,10 +299,14 @@ import { FloatingToolbarDemoComponent } from './floating-toolbar-demo/floating-t
<ui-transfer-list-demo></ui-transfer-list-demo> <ui-transfer-list-demo></ui-transfer-list-demo>
} }
@case ("tag-input") {
<ui-tag-input-demo></ui-tag-input-demo>
}
} }
`, `,
imports: [AvatarDemoComponent, ButtonDemoComponent, CardDemoComponent, imports: [AvatarDemoComponent, ButtonDemoComponent, IconButtonDemoComponent, CardDemoComponent,
ChipDemoComponent, TableDemoComponent, BadgeDemoComponent, ChipDemoComponent, TableDemoComponent, BadgeDemoComponent,
MenuDemoComponent, InputDemoComponent, MenuDemoComponent, InputDemoComponent,
LayoutDemoComponent, RadioDemoComponent, CheckboxDemoComponent, LayoutDemoComponent, RadioDemoComponent, CheckboxDemoComponent,
@@ -308,7 +318,7 @@ import { FloatingToolbarDemoComponent } from './floating-toolbar-demo/floating-t
SkeletonLoaderDemoComponent, EmptyStateDemoComponent, FileUploadDemoComponent, FormFieldDemoComponent, SkeletonLoaderDemoComponent, EmptyStateDemoComponent, FileUploadDemoComponent, FormFieldDemoComponent,
AutocompleteDemoComponent, BackdropDemoComponent, OverlayContainerDemoComponent, LoadingSpinnerDemoComponent, AutocompleteDemoComponent, BackdropDemoComponent, OverlayContainerDemoComponent, LoadingSpinnerDemoComponent,
ProgressCircleDemoComponent, RangeSliderDemoComponent, ColorPickerDemoComponent, DividerDemoComponent, TooltipDemoComponent, AccordionDemoComponent, 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]
}) })

View File

@@ -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;
}
}

View File

@@ -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: `
<div style="padding: 2rem;">
<h2>Enhanced Table Component Showcase</h2>
<p style="margin-bottom: 2rem; color: #6c757d;">Enterprise-grade data table with virtual scrolling, column resizing/reordering, and advanced filtering.</p>
<!-- Basic Enhanced Table -->
<section style="margin-bottom: 3rem;">
<h3>Basic Enhanced Table</h3>
<ui-enhanced-table
[data]="smallDataset"
[columns]="basicColumns"
variant="default"
[virtualScrolling]="{ enabled: false, itemHeight: 48, buffer: 5 }"
[showFilterRow]="true"
[maxHeight]="'400px'"
(sortChange)="handleSort($event)"
(filterChange)="handleFilter($event)"
(columnResize)="handleColumnResize($event)"
(columnReorder)="handleColumnReorder($event)"
(rowClick)="handleRowClick($event)">
</ui-enhanced-table>
</section>
<!-- Table Variants -->
<section style="margin-bottom: 3rem;">
<h3>Table Variants</h3>
<div style="margin-bottom: 2rem;">
<h4>Striped Enhanced Table</h4>
<ui-enhanced-table
[data]="smallDataset.slice(0, 5)"
[columns]="basicColumns"
variant="striped"
[virtualScrolling]="{ enabled: false, itemHeight: 48, buffer: 5 }"
[showFilterRow]="false"
[maxHeight]="'300px'">
</ui-enhanced-table>
</div>
<div style="margin-bottom: 2rem;">
<h4>Bordered Enhanced Table</h4>
<ui-enhanced-table
[data]="smallDataset.slice(0, 5)"
[columns]="basicColumns"
variant="bordered"
[virtualScrolling]="{ enabled: false, itemHeight: 48, buffer: 5 }"
[showFilterRow]="false"
[maxHeight]="'300px'">
</ui-enhanced-table>
</div>
<div style="margin-bottom: 2rem;">
<h4>Minimal Enhanced Table</h4>
<ui-enhanced-table
[data]="smallDataset.slice(0, 5)"
[columns]="basicColumns"
variant="minimal"
[virtualScrolling]="{ enabled: false, itemHeight: 48, buffer: 5 }"
[showFilterRow]="false"
[maxHeight]="'300px'">
</ui-enhanced-table>
</div>
</section>
<!-- Virtual Scrolling Demo -->
<section style="margin-bottom: 3rem;">
<h3>Virtual Scrolling ({{ largeDataset.length.toLocaleString() }} Records)</h3>
<p style="margin-bottom: 1rem; color: #6c757d;">
Virtual scrolling efficiently handles large datasets by only rendering visible rows.
Performance metrics are displayed below the table.
</p>
<ui-enhanced-table
[data]="largeDataset"
[columns]="performanceColumns"
variant="default"
[virtualScrolling]="virtualConfig"
[showFilterRow]="true"
[maxHeight]="'500px'"
[cellTemplates]="cellTemplates"
(sortChange)="handleSort($event)"
(filterChange)="handleFilter($event)"
(columnResize)="handleColumnResize($event)"
(columnReorder)="handleColumnReorder($event)">
</ui-enhanced-table>
@if (performanceMetrics) {
<div style="margin-top: 1rem; padding: 1rem; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #007bff;">
<h4>Performance Metrics:</h4>
<div style="display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 1rem; margin-top: 0.5rem;">
<div><strong>Render Time:</strong> {{ performanceMetrics.renderTime }}ms</div>
<div><strong>Sort Time:</strong> {{ performanceMetrics.sortTime }}ms</div>
<div><strong>Filter Time:</strong> {{ performanceMetrics.filterTime }}ms</div>
<div><strong>Visible Rows:</strong> {{ performanceMetrics.visibleRows }}</div>
</div>
</div>
}
</section>
<!-- Advanced Features -->
<section style="margin-bottom: 3rem;">
<h3>Advanced Features with Custom Cell Templates</h3>
<ui-enhanced-table
[data]="smallDataset"
[columns]="advancedColumns"
variant="default"
[virtualScrolling]="{ enabled: false, itemHeight: 48, buffer: 5 }"
[showFilterRow]="true"
[maxHeight]="'400px'"
[cellTemplates]="cellTemplates"
(sortChange)="handleSort($event)"
(filterChange)="handleFilter($event)"
(rowClick)="handleRowClick($event)">
</ui-enhanced-table>
<!-- Templates -->
<ng-template #statusTemplate let-value let-row="row">
<ui-status-badge
[variant]="getStatusVariant(value)"
[dot]="true"
size="medium">
{{ value | titlecase }}
</ui-status-badge>
</ng-template>
<ng-template #salaryTemplate let-value>
<span style="font-weight: 600; color: #28a745;">
{{ value | currency:'USD':'symbol':'1.0-0' }}
</span>
</ng-template>
<ng-template #performanceTemplate let-value>
<div style="display: flex; align-items: center; gap: 0.5rem;">
<div style="flex: 1; background: #e9ecef; border-radius: 4px; height: 8px; overflow: hidden;">
<div
[style.width.%]="value"
[style.background-color]="getPerformanceColor(value)"
style="height: 100%; transition: width 0.3s ease;">
</div>
</div>
<span style="font-size: 0.875rem; min-width: 35px;">{{ value }}%</span>
</div>
</ng-template>
<ng-template #actionsTemplate let-row="row" let-index="index">
<div style="display: flex; gap: 0.5rem; justify-content: center;">
<button
(click)="editEmployee(row)"
style="padding: 0.25rem 0.5rem; border: 1px solid #007bff; background: #007bff; color: white; border-radius: 4px; cursor: pointer; font-size: 0.75rem;">
Edit
</button>
<button
(click)="deleteEmployee(row.id)"
style="padding: 0.25rem 0.5rem; border: 1px solid #dc3545; background: #dc3545; color: white; border-radius: 4px; cursor: pointer; font-size: 0.75rem;">
Delete
</button>
</div>
</ng-template>
</section>
<!-- Column Configuration Demo -->
<section style="margin-bottom: 3rem;">
<h3>Interactive Column Configuration</h3>
<div style="display: flex; gap: 2rem; margin-bottom: 1.5rem; flex-wrap: wrap;">
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" [(ngModel)]="enableResize" (change)="updateTableConfig()">
Enable Column Resizing
</label>
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" [(ngModel)]="enableReorder" (change)="updateTableConfig()">
Enable Column Reordering
</label>
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" [(ngModel)]="showTableFilters" (change)="updateTableConfig()">
Show Filters
</label>
<label style="display: flex; align-items: center; gap: 0.5rem;">
<input type="checkbox" [(ngModel)]="enableVirtual" (change)="updateTableConfig()">
Enable Virtual Scrolling
</label>
</div>
<ui-enhanced-table
[data]="configDataset"
[columns]="configColumns"
variant="default"
[virtualScrolling]="configVirtualConfig"
[showFilterRow]="showTableFilters"
[maxHeight]="'400px'"
(sortChange)="handleSort($event)"
(filterChange)="handleFilter($event)"
(columnResize)="handleColumnResize($event)"
(columnReorder)="handleColumnReorder($event)">
</ui-enhanced-table>
</section>
<!-- Usage Examples -->
<section style="margin-bottom: 3rem;">
<h3>Usage Examples</h3>
<div style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; border-left: 4px solid #007bff;">
<h4>Basic Enhanced Table:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;ui-enhanced-table
[data]="employees"
[columns]="columns"
variant="default"
[enableColumnResize]="true"
[enableColumnReorder]="true"
[showFilters]="true"
[height]="500"
(sort)="handleSort($event)"
(filter)="handleFilter($event)"&gt;
&lt;/ui-enhanced-table&gt;</code></pre>
<h4>With Virtual Scrolling:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;ui-enhanced-table
[data]="largeDataset"
[columns]="columns"
[virtualScrollConfig]="{{ '{' }}
enabled: true,
itemHeight: 48,
bufferSize: 10
{{ '}' }}"
[height]="600"&gt;
&lt;/ui-enhanced-table&gt;</code></pre>
<h4>Column Configuration:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>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
{{ '}' }}
];</code></pre>
</div>
</section>
<!-- Interactive Controls -->
<section style="margin-bottom: 3rem;">
<h3>Interactive Demo Controls</h3>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
<button (click)="addRandomEmployee()" style="padding: 0.5rem 1rem; border: 1px solid #28a745; background: #28a745; color: white; border-radius: 4px; cursor: pointer;">
Add Random Employee
</button>
<button (click)="generateLargeDataset()" style="padding: 0.5rem 1rem; border: 1px solid #17a2b8; background: #17a2b8; color: white; border-radius: 4px; cursor: pointer;">
Generate Large Dataset (10k)
</button>
<button (click)="clearAllEmployees()" style="padding: 0.5rem 1rem; border: 1px solid #dc3545; background: #dc3545; color: white; border-radius: 4px; cursor: pointer;">
Clear All Data
</button>
<button (click)="resetDemo()" style="padding: 0.5rem 1rem; border: 1px solid #6c757d; background: #6c757d; color: white; border-radius: 4px; cursor: pointer;">
Reset Demo
</button>
</div>
@if (lastAction) {
<div style="padding: 1rem; background: #d4edda; border: 1px solid #c3e6cb; border-radius: 4px; color: #155724;">
<strong>Last Action:</strong> {{ lastAction }}
</div>
}
</section>
</div>
`,
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<any>;
@ViewChild('salaryTemplate') salaryTemplate!: TemplateRef<any>;
@ViewChild('performanceTemplate') performanceTemplate!: TemplateRef<any>;
@ViewChild('actionsTemplate') actionsTemplate!: TemplateRef<any>;
// Demo state
lastAction = '';
performanceMetrics: any = null;
// Configuration
enableResize = true;
enableReorder = true;
showTableFilters = true;
enableVirtual = false;
// Cell templates
cellTemplates: Record<string, TemplateRef<any>> = {};
// 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}`;
}
}

View File

@@ -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;
}

View File

@@ -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: `
<div class="demo-container">
<h2>Floating Action Button Menu Demo</h2>
<p>An interactive floating action button menu that expands to reveal multiple action items.</p>
<!-- Size Variants -->
<section class="demo-section">
<h3>Sizes</h3>
<div class="demo-row">
@for (size of sizes; track size) {
<div class="demo-example">
<h4>{{ size | titlecase }}</h4>
<ui-fab-menu
[size]="size"
[menuItems]="basicMenuItems"
[triggerIcon]="plusIcon"
[closeIcon]="closeIcon"
triggerLabel="Open {{ size }} menu"
(itemClicked)="handleItemClick($event)">
</ui-fab-menu>
</div>
}
</div>
</section>
<!-- Color Variants -->
<section class="demo-section">
<h3>Color Variants</h3>
<div class="demo-row">
@for (variant of variants; track variant) {
<div class="demo-example">
<h4>{{ variant | titlecase }}</h4>
<ui-fab-menu
[variant]="variant"
[menuItems]="getVariantMenuItems(variant)"
[triggerIcon]="plusIcon"
[closeIcon]="closeIcon"
[triggerLabel]="variant + ' menu'"
(itemClicked)="handleItemClick($event)">
</ui-fab-menu>
</div>
}
</div>
</section>
<!-- Direction Variants -->
<section class="demo-section">
<h3>Direction Variants</h3>
<div class="demo-row demo-row--spaced">
@for (direction of directions; track direction) {
<div class="demo-example">
<h4>{{ direction | titlecase }}</h4>
<ui-fab-menu
[direction]="direction"
[menuItems]="basicMenuItems"
[triggerIcon]="plusIcon"
[closeIcon]="closeIcon"
[triggerLabel]="direction + ' direction menu'"
(itemClicked)="handleItemClick($event)">
</ui-fab-menu>
</div>
}
</div>
</section>
<!-- Position Variants (Fixed Positioning) -->
<section class="demo-section">
<h3>Fixed Positions</h3>
<p>These examples show fixed positioning variants (click to activate):</p>
<div class="demo-row">
@for (position of positions; track position) {
<button
class="demo-button"
(click)="togglePositionDemo(position)">
Show {{ position | titlecase }} FAB Menu
</button>
}
</div>
@if (activePositionDemo) {
<ui-fab-menu
[position]="activePositionDemo"
[menuItems]="positionMenuItems"
[triggerIcon]="menuIcon"
[closeIcon]="closeIcon"
[triggerLabel]="activePositionDemo + ' positioned menu'"
(opened)="onMenuOpened()"
(closed)="onMenuClosed()"
(itemClicked)="handlePositionItemClick($event)">
</ui-fab-menu>
}
</section>
<!-- States -->
<section class="demo-section">
<h3>States</h3>
<div class="demo-row">
<div class="demo-example">
<h4>Disabled</h4>
<ui-fab-menu
[disabled]="true"
[menuItems]="basicMenuItems"
[triggerIcon]="plusIcon"
triggerLabel="Disabled menu">
</ui-fab-menu>
</div>
<div class="demo-example">
<h4>No Backdrop</h4>
<ui-fab-menu
[backdrop]="false"
[menuItems]="basicMenuItems"
[triggerIcon]="plusIcon"
[closeIcon]="closeIcon"
triggerLabel="Menu without backdrop"
(itemClicked)="handleItemClick($event)">
</ui-fab-menu>
</div>
<div class="demo-example">
<h4>Stay Open</h4>
<ui-fab-menu
[closeOnItemClick]="false"
[menuItems]="basicMenuItems"
[triggerIcon]="plusIcon"
[closeIcon]="closeIcon"
triggerLabel="Menu stays open"
(itemClicked)="handleItemClick($event)">
</ui-fab-menu>
</div>
</div>
</section>
<!-- Advanced Examples -->
<section class="demo-section">
<h3>Advanced Examples</h3>
<div class="demo-example">
<h4>Mixed Item Variants</h4>
<ui-fab-menu
[menuItems]="mixedMenuItems"
[triggerIcon]="settingsIcon"
[closeIcon]="closeIcon"
triggerLabel="Settings menu"
(itemClicked)="handleItemClick($event)">
</ui-fab-menu>
</div>
<div class="demo-example">
<h4>Large Menu with Icons</h4>
<ui-fab-menu
size="lg"
[menuItems]="iconMenuItems"
[triggerIcon]="appsIcon"
[closeIcon]="closeIcon"
triggerLabel="App launcher"
(itemClicked)="handleItemClick($event)">
</ui-fab-menu>
</div>
</section>
<!-- Interactive Feedback -->
<section class="demo-section">
<h3>Event Log</h3>
<div class="demo-log">
@if (eventLog.length === 0) {
<p class="demo-log__empty">No events yet. Interact with the FAB menus above.</p>
} @else {
@for (event of eventLog.slice(-10); track $index) {
<div class="demo-log__entry">
<span class="demo-log__time">{{ event.timestamp | date:'HH:mm:ss' }}</span>
<span class="demo-log__message">{{ event.message }}</span>
</div>
}
}
</div>
<button class="demo-button demo-button--secondary" (click)="clearLog()">Clear Log</button>
</section>
</div>
`,
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 = '<span>+</span>';
closeIcon = '<span>×</span>';
menuIcon = '<span>☰</span>';
settingsIcon = '<span>⚙</span>';
appsIcon = '<span>⊞</span>';
basicMenuItems: FabMenuItem[] = [
{ id: 'action1', label: 'Action 1', icon: '<span>📝</span>' },
{ id: 'action2', label: 'Action 2', icon: '<span>📁</span>' },
{ id: 'action3', label: 'Action 3', icon: '<span>📊</span>' }
];
positionMenuItems: FabMenuItem[] = [
{ id: 'close', label: 'Close Menu', icon: '<span>×</span>' },
{ id: 'home', label: 'Go Home', icon: '<span>🏠</span>' },
{ id: 'profile', label: 'Profile', icon: '<span>👤</span>' },
{ id: 'settings', label: 'Settings', icon: '<span>⚙</span>' }
];
mixedMenuItems: FabMenuItem[] = [
{ id: 'save', label: 'Save', variant: 'success', icon: '<span>💾</span>' },
{ id: 'edit', label: 'Edit', variant: 'primary', icon: '<span>✏️</span>' },
{ id: 'delete', label: 'Delete', variant: 'danger', icon: '<span>🗑️</span>' },
{ id: 'disabled', label: 'Disabled', disabled: true, icon: '<span>🚫</span>' }
];
iconMenuItems: FabMenuItem[] = [
{ id: 'email', label: 'Email', icon: '<span>📧</span>' },
{ id: 'calendar', label: 'Calendar', icon: '<span>📅</span>' },
{ id: 'documents', label: 'Documents', icon: '<span>📄</span>' },
{ id: 'photos', label: 'Photos', icon: '<span>📸</span>' },
{ id: 'music', label: 'Music', icon: '<span>🎵</span>' },
{ id: 'videos', label: 'Videos', icon: '<span>🎬</span>' }
];
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');
}
}

View File

@@ -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;
}
}

View File

@@ -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: `
<div class="demo-container">
<h2>Floating Toolbar Demo</h2>
<!-- Size Variants -->
<section class="demo-section">
<h3>Size Variants</h3>
<div class="demo-row">
@for (size of sizes; track size) {
<div class="demo-item">
<button
class="demo-trigger-button"
#triggerButton
(click)="showToolbar('size-' + size, triggerButton, { size: size })"
>
{{ size }} Toolbar
</button>
</div>
}
</div>
</section>
<!-- Variant Styles -->
<section class="demo-section">
<h3>Style Variants</h3>
<div class="demo-row">
@for (variant of variants; track variant) {
<div class="demo-item">
<button
class="demo-trigger-button"
#triggerButton
(click)="showToolbar('variant-' + variant, triggerButton, { variant: variant })"
>
{{ variant | titlecase }} Style
</button>
</div>
}
</div>
</section>
<!-- Position Variants -->
<section class="demo-section">
<h3>Position Variants</h3>
<div class="demo-row demo-row--center">
<div class="demo-position-container">
@for (position of positions; track position) {
<button
class="demo-position-button demo-position-button--{{ position }}"
#triggerButton
(click)="showToolbar('position-' + position, triggerButton, { position: position })"
>
{{ position }}
</button>
}
</div>
</div>
</section>
<!-- Context-Sensitive Actions -->
<section class="demo-section">
<h3>Context-Sensitive Actions</h3>
<div class="demo-row">
<div class="demo-context-area" #textContext>
<p>
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.
</p>
</div>
<div class="demo-context-area" #imageContext>
<img
src="data:image/svg+xml;base64,PHN2ZyB3aWR0aD0iMjAwIiBoZWlnaHQ9IjEwMCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4KICA8cmVjdCB3aWR0aD0iMTAwJSIgaGVpZ2h0PSIxMDAlIiBmaWxsPSIjZGRkIi8+CiAgPHRleHQgeD0iNTAlIiB5PSI1MCUiIGZvbnQtZmFtaWx5PSJBcmlhbCIgZm9udC1zaXplPSIxNCIgZmlsbD0iIzk5OSIgdGV4dC1hbmNob3I9Im1pZGRsZSIgZHk9Ii4zZW0iPkltYWdlPC90ZXh0Pgo8L3N2Zz4K"
alt="Demo Image"
(contextmenu)="showContextualToolbar($event, imageActions, 'image')"
/>
<p class="demo-hint">Right-click the image for image editing options</p>
</div>
</div>
</section>
<!-- Auto-Hide Behavior -->
<section class="demo-section">
<h3>Auto-Hide Behavior</h3>
<div class="demo-row">
<button
class="demo-trigger-button"
#autoHideButton
(click)="showAutoHideToolbar(autoHideButton)"
>
Show Auto-Hide Toolbar (3 seconds)
</button>
</div>
</section>
<!-- Custom Actions Demo -->
<section class="demo-section">
<h3>Custom Actions</h3>
<div class="demo-row">
<button
class="demo-trigger-button"
#customButton
(click)="showCustomActionsToolbar(customButton)"
>
Custom Actions Toolbar
</button>
</div>
<div class="demo-output">
<h4>Action Log:</h4>
<div class="demo-log">
@for (logEntry of actionLog; track $index) {
<div class="demo-log-entry">{{ logEntry }}</div>
}
@if (actionLog.length === 0) {
<div class="demo-log-empty">No actions performed yet...</div>
}
</div>
</div>
</section>
<!-- Interactive Examples -->
<section class="demo-section">
<h3>Interactive Examples</h3>
<!-- Editable Text Area -->
<div class="demo-subsection">
<h4>Editable Text with Formatting Toolbar</h4>
<div
class="demo-editor"
contenteditable="true"
#editableArea
(mouseup)="handleTextSelection(editableArea)"
(keyup)="handleTextSelection(editableArea)"
>
<p>This is an editable text area. Select text to see formatting options in the floating toolbar.</p>
<p><strong>Bold text</strong>, <em>italic text</em>, and <u>underlined text</u> are supported.</p>
</div>
</div>
<!-- Data Table with Row Actions -->
<div class="demo-subsection">
<h4>Data Table with Row Actions</h4>
<table class="demo-table">
<thead>
<tr>
<th>Name</th>
<th>Email</th>
<th>Status</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
@for (user of sampleUsers; track user.id) {
<tr
#tableRow
class="demo-table-row"
[class.demo-table-row--selected]="selectedUserId === user.id"
(click)="selectUser(user.id, tableRow)"
>
<td>{{ user.name }}</td>
<td>{{ user.email }}</td>
<td>{{ user.status }}</td>
<td>
<button
class="demo-table-action"
(click)="showUserActions($event, tableRow, user)"
>
</button>
</td>
</tr>
}
</tbody>
</table>
</div>
</section>
<!-- Configuration Demo -->
<section class="demo-section">
<h3>Configuration Options</h3>
<div class="demo-controls">
<div class="demo-control-group">
<label>
<input
type="checkbox"
[(ngModel)]="demoConfig.backdrop"
(change)="updateDemoConfig()"
/>
Show Backdrop
</label>
<label>
<input
type="checkbox"
[(ngModel)]="demoConfig.autoHide"
(change)="updateDemoConfig()"
/>
Auto Hide
</label>
<label>
<input
type="checkbox"
[(ngModel)]="demoConfig.escapeClosable"
(change)="updateDemoConfig()"
/>
ESC to Close
</label>
</div>
<button
class="demo-trigger-button"
#configButton
(click)="showConfigurableToolbar(configButton)"
>
Test Configuration
</button>
</div>
</section>
</div>
<!-- Floating Toolbars -->
<ui-floating-toolbar
#floatingToolbar
[actions]="currentActions"
[size]="getConfigValue('size')"
[variant]="getConfigValue('variant')"
[position]="getConfigValue('position')"
[visible]="getConfigValue('visible')"
[autoHide]="getConfigValue('autoHide')"
[hideDelay]="getConfigValue('hideDelay')"
[backdrop]="getConfigValue('backdrop')"
[escapeClosable]="getConfigValue('escapeClosable')"
[label]="getConfigValue('label')"
[showLabel]="getConfigValue('showLabel')"
[showShortcuts]="getConfigValue('showShortcuts')"
(actionClicked)="onActionClicked($event)"
(visibleChange)="onToolbarVisibilityChange($event)"
(hidden)="onToolbarHidden()"
></ui-floating-toolbar>
<!-- Text Selection Toolbar -->
<ui-floating-toolbar
#textSelectionToolbar
[actions]="textActions"
size="sm"
variant="contextual"
position="top"
[visible]="false"
trigger="selection"
[autoHide]="false"
label="Text Formatting"
[showShortcuts]="true"
(actionClicked)="onTextActionClicked($event)"
></ui-floating-toolbar>
`,
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<FloatingToolbarConfig>): 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<K extends keyof FloatingToolbarConfig>(key: K): NonNullable<FloatingToolbarConfig[K]> {
const defaultValues: Required<FloatingToolbarConfig> = {
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<FloatingToolbarConfig[K]>;
}
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);
}
}
}

View File

@@ -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;
}
}
}

View File

@@ -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: `
<div style="padding: 2rem;">
<h2>Icon Button Component Showcase</h2>
<!-- Size Variants -->
<section style="margin-bottom: 3rem;">
<h3>Size Variants</h3>
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
<ui-icon-button
[icon]="faHeart"
size="small"
ariaLabel="Like (small)"
(clicked)="handleClick('small heart')">
</ui-icon-button>
<ui-icon-button
[icon]="faHeart"
size="medium"
ariaLabel="Like (medium)"
(clicked)="handleClick('medium heart')">
</ui-icon-button>
<ui-icon-button
[icon]="faHeart"
size="large"
ariaLabel="Like (large)"
(clicked)="handleClick('large heart')">
</ui-icon-button>
</div>
</section>
<!-- Filled Variant -->
<section style="margin-bottom: 3rem;">
<h3>Filled Variant</h3>
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
<ui-icon-button
[icon]="faHeart"
variant="filled"
ariaLabel="Like"
title="Add to favorites"
(clicked)="handleClick('filled heart')">
</ui-icon-button>
<ui-icon-button
[icon]="faBookmark"
variant="filled"
ariaLabel="Bookmark"
title="Save for later"
(clicked)="handleClick('filled bookmark')">
</ui-icon-button>
<ui-icon-button
[icon]="faShare"
variant="filled"
ariaLabel="Share"
title="Share this item"
(clicked)="handleClick('filled share')">
</ui-icon-button>
<ui-icon-button
[icon]="faDownload"
variant="filled"
ariaLabel="Download"
title="Download file"
(clicked)="handleClick('filled download')">
</ui-icon-button>
</div>
</section>
<!-- Tonal Variant -->
<section style="margin-bottom: 3rem;">
<h3>Tonal Variant</h3>
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
<ui-icon-button
[icon]="faEdit"
variant="tonal"
ariaLabel="Edit"
title="Edit item"
(clicked)="handleClick('tonal edit')">
</ui-icon-button>
<ui-icon-button
[icon]="faSave"
variant="tonal"
ariaLabel="Save"
title="Save changes"
(clicked)="handleClick('tonal save')">
</ui-icon-button>
<ui-icon-button
[icon]="faSearch"
variant="tonal"
ariaLabel="Search"
title="Search items"
(clicked)="handleClick('tonal search')">
</ui-icon-button>
<ui-icon-button
[icon]="faCog"
variant="tonal"
ariaLabel="Settings"
title="Open settings"
(clicked)="handleClick('tonal settings')">
</ui-icon-button>
</div>
</section>
<!-- Outlined Variant -->
<section style="margin-bottom: 3rem;">
<h3>Outlined Variant</h3>
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
<ui-icon-button
[icon]="faPlus"
variant="outlined"
ariaLabel="Add"
title="Add new item"
(clicked)="handleClick('outlined add')">
</ui-icon-button>
<ui-icon-button
[icon]="faMinus"
variant="outlined"
ariaLabel="Remove"
title="Remove item"
(clicked)="handleClick('outlined remove')">
</ui-icon-button>
<ui-icon-button
[icon]="faHome"
variant="outlined"
ariaLabel="Home"
title="Go home"
(clicked)="handleClick('outlined home')">
</ui-icon-button>
<ui-icon-button
[icon]="faUser"
variant="outlined"
ariaLabel="Profile"
title="View profile"
(clicked)="handleClick('outlined profile')">
</ui-icon-button>
</div>
</section>
<!-- States -->
<section style="margin-bottom: 3rem;">
<h3>States</h3>
<div style="display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
<ui-icon-button
[icon]="faHeart"
variant="filled"
[disabled]="true"
ariaLabel="Disabled button">
</ui-icon-button>
<ui-icon-button
[icon]="faDownload"
variant="filled"
[loading]="true"
ariaLabel="Loading button">
</ui-icon-button>
<ui-icon-button
[icon]="faStar"
variant="tonal"
[pressed]="isPressed"
ariaLabel="Toggle star"
title="Toggle favorite"
(clicked)="togglePressed()">
</ui-icon-button>
</div>
<p style="margin-top: 0.5rem; color: #666; font-size: 0.9rem;">
Star button pressed state: {{ isPressed ? 'ON' : 'OFF' }}
</p>
</section>
<!-- Navigation Icons -->
<section style="margin-bottom: 3rem;">
<h3>Navigation Examples</h3>
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
<ui-icon-button
[icon]="faChevronLeft"
variant="outlined"
ariaLabel="Previous"
title="Go to previous page"
(clicked)="handleClick('previous')">
</ui-icon-button>
<ui-icon-button
[icon]="faChevronRight"
variant="outlined"
ariaLabel="Next"
title="Go to next page"
(clicked)="handleClick('next')">
</ui-icon-button>
<ui-icon-button
[icon]="faClose"
variant="tonal"
ariaLabel="Close"
title="Close dialog"
(clicked)="handleClick('close')">
</ui-icon-button>
<ui-icon-button
[icon]="faCheck"
variant="filled"
ariaLabel="Confirm"
title="Confirm action"
(clicked)="handleClick('confirm')">
</ui-icon-button>
<ui-icon-button
[icon]="faEllipsisV"
variant="tonal"
ariaLabel="More options"
title="Show more options"
(clicked)="handleClick('more')">
</ui-icon-button>
</div>
</section>
<!-- Destructive Actions -->
<section style="margin-bottom: 3rem;">
<h3>Destructive Actions</h3>
<div style="display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem;">
<ui-icon-button
[icon]="faTrash"
variant="outlined"
ariaLabel="Delete"
title="Delete item (destructive action)"
class="danger-button"
(clicked)="handleClick('delete')">
</ui-icon-button>
</div>
<p style="margin-top: 0.5rem; color: #666; font-size: 0.9rem;">
Use outlined variant for destructive actions to reduce accidental clicks
</p>
</section>
<!-- Click Counter -->
<section style="margin-bottom: 2rem;">
<h3>Interaction Test</h3>
<p>Total clicks: {{ clickCount }}</p>
<p>Last clicked: {{ lastClicked || 'None' }}</p>
</section>
</div>
`,
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'}`);
}
}

View File

@@ -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;
}

View File

@@ -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: `
<div class="demo-container">
<h2>Snackbar Demo</h2>
<!-- Size Variants -->
<section class="demo-section">
<h3>Sizes</h3>
<div class="demo-row" style="gap: 16px; flex-direction: column; align-items: flex-start;">
@for (size of sizes; track size) {
<ui-snackbar
[size]="size"
[message]="size + ' snackbar - This is a brief message'"
[autoDismiss]="false"
[position]="'bottom-left'"
style="position: relative; bottom: auto; left: auto; transform: none; margin: 0;">
</ui-snackbar>
}
</div>
</section>
<!-- Color Variants -->
<section class="demo-section">
<h3>Variants</h3>
<div class="demo-row" style="gap: 16px; flex-direction: column; align-items: flex-start;">
@for (variant of variants; track variant) {
<ui-snackbar
[variant]="variant"
[message]="variant + ' - This is a ' + variant + ' snackbar message'"
[showIcon]="variant !== 'default'"
[autoDismiss]="false"
[position]="'bottom-left'"
style="position: relative; bottom: auto; left: auto; transform: none; margin: 0;">
</ui-snackbar>
}
</div>
</section>
<!-- With Actions -->
<section class="demo-section">
<h3>With Actions</h3>
<div class="demo-row" style="gap: 16px; flex-direction: column; align-items: flex-start;">
<ui-snackbar
message="Your file was saved successfully"
variant="success"
[showIcon]="true"
[actions]="singleAction"
[autoDismiss]="false"
[position]="'bottom-left'"
style="position: relative; bottom: auto; left: auto; transform: none; margin: 0;">
</ui-snackbar>
<ui-snackbar
message="Connection lost. Check your network and try again."
variant="danger"
[showIcon]="true"
[actions]="multipleActions"
[autoDismiss]="false"
[position]="'bottom-left'"
style="position: relative; bottom: auto; left: auto; transform: none; margin: 0;">
</ui-snackbar>
</div>
</section>
<!-- Position Variants -->
<section class="demo-section">
<h3>Positions</h3>
<div class="demo-row" style="gap: 8px;">
@for (position of positions; track position) {
<button
class="demo-button"
(click)="showPositionDemo(position)">
{{ position }}
</button>
}
</div>
<p>Click buttons to see snackbars in different positions</p>
</section>
<!-- Interactive Examples -->
<section class="demo-section">
<h3>Interactive Examples</h3>
<div class="demo-row" style="gap: 8px;">
<button
class="demo-button demo-button--primary"
(click)="showSuccessSnackbar()">
Show Success
</button>
<button
class="demo-button demo-button--warning"
(click)="showWarningSnackbar()">
Show Warning
</button>
<button
class="demo-button demo-button--danger"
(click)="showErrorSnackbar()">
Show Error
</button>
<button
class="demo-button"
(click)="showActionSnackbar()">
Show with Action
</button>
</div>
</section>
<!-- Dynamic Snackbars -->
@for (snackbar of activeSnackbars; track snackbar.id) {
<ui-snackbar
[size]="snackbar.size"
[variant]="snackbar.variant"
[message]="snackbar.message"
[showIcon]="snackbar.showIcon"
[actions]="snackbar.actions"
[position]="snackbar.position"
[autoDismiss]="snackbar.autoDismiss"
[duration]="snackbar.duration"
(dismissed)="removeSnackbar(snackbar.id)"
(expired)="removeSnackbar(snackbar.id)"
(actionClicked)="handleActionClick($event, snackbar.id)">
</ui-snackbar>
}
<!-- Status -->
<section class="demo-section">
<h3>Demo Status</h3>
<p>Active snackbars: {{ activeSnackbars.length }}</p>
<p>Total shown: {{ totalShown }}</p>
<p>Last action: {{ lastAction || 'None' }}</p>
</section>
</div>
`,
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;
}
}

View File

@@ -10,10 +10,10 @@
margin-bottom: $semantic-spacing-layout-section-lg; margin-bottom: $semantic-spacing-layout-section-lg;
h3 { h3 {
font-family: map-get($semantic-typography-heading-h3, font-family); font-family: $semantic-typography-font-family-sans;
font-size: map-get($semantic-typography-heading-h3, font-size); font-size: $semantic-typography-font-size-lg;
font-weight: map-get($semantic-typography-heading-h3, font-weight); font-weight: $semantic-typography-font-weight-semibold;
line-height: map-get($semantic-typography-heading-h3, line-height); line-height: $semantic-typography-line-height-tight;
color: $semantic-color-text-primary; color: $semantic-color-text-primary;
margin-bottom: $semantic-spacing-component-md; margin-bottom: $semantic-spacing-component-md;
border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary; border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary;
@@ -36,10 +36,10 @@
border-radius: $semantic-border-radius-md; border-radius: $semantic-border-radius-md;
p { p {
font-family: map-get($semantic-typography-body-medium, font-family); font-family: $semantic-typography-font-family-sans;
font-size: map-get($semantic-typography-body-medium, font-size); font-size: $semantic-typography-font-size-md;
font-weight: map-get($semantic-typography-body-medium, font-weight); font-weight: $semantic-typography-font-weight-normal;
line-height: map-get($semantic-typography-body-medium, line-height); line-height: $semantic-typography-line-height-normal;
color: $semantic-color-text-primary; color: $semantic-color-text-primary;
margin: $semantic-spacing-component-xs 0; margin: $semantic-spacing-component-xs 0;

View File

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

View File

@@ -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: `
<div class="demo-container">
<h2>Stepper Demo</h2>
<!-- Orientation Variants -->
<section class="demo-section">
<h3>Orientation</h3>
<div class="demo-subsection">
<h4>Horizontal Stepper</h4>
<ui-stepper
[steps]="horizontalSteps"
orientation="horizontal"
[clickable]="true"
(stepClick)="onStepClick('horizontal', $event)"
(stepChange)="onStepChange('horizontal', $event)">
</ui-stepper>
</div>
<div class="demo-subsection">
<h4>Vertical Stepper</h4>
<div class="vertical-stepper-container">
<ui-stepper
[steps]="verticalSteps"
orientation="vertical"
[clickable]="true"
(stepClick)="onStepClick('vertical', $event)"
(stepChange)="onStepChange('vertical', $event)">
</ui-stepper>
</div>
</div>
</section>
<!-- Size Variants -->
<section class="demo-section">
<h3>Sizes</h3>
<div class="demo-row">
@for (size of sizes; track size) {
<div class="demo-item">
<h4>{{ size }} Size</h4>
<ui-stepper
[steps]="sizeSteps"
[size]="size"
orientation="horizontal">
</ui-stepper>
</div>
}
</div>
</section>
<!-- Style Variants -->
<section class="demo-section">
<h3>Variants</h3>
<div class="demo-row">
<div class="demo-item">
<h4>Default</h4>
<ui-stepper
[steps]="variantSteps"
variant="default">
</ui-stepper>
</div>
<div class="demo-item">
<h4>Outlined</h4>
<ui-stepper
[steps]="variantSteps"
variant="outlined">
</ui-stepper>
</div>
</div>
</section>
<!-- Dense Variant -->
<section class="demo-section">
<h3>Dense Layout</h3>
<div class="demo-row">
<div class="demo-item">
<h4>Normal</h4>
<ui-stepper
[steps]="denseSteps"
[dense]="false">
</ui-stepper>
</div>
<div class="demo-item">
<h4>Dense</h4>
<ui-stepper
[steps]="denseSteps"
[dense]="true">
</ui-stepper>
</div>
</div>
</section>
<!-- Step States -->
<section class="demo-section">
<h3>Step States</h3>
<div class="demo-subsection">
<h4>All States</h4>
<ui-stepper
[steps]="stateSteps"
orientation="horizontal">
</ui-stepper>
</div>
</section>
<!-- Linear vs Non-Linear -->
<section class="demo-section">
<h3>Navigation Modes</h3>
<div class="demo-row">
<div class="demo-item">
<h4>Linear (Default)</h4>
<ui-stepper
[steps]="linearSteps"
[clickable]="true"
[linear]="true"
(stepClick)="onStepClick('linear', $event)">
</ui-stepper>
<div class="demo-controls">
<button (click)="nextLinearStep()" class="demo-button">
<fa-icon [icon]="playIcon"></fa-icon>
Next Step
</button>
<button (click)="resetLinearStepper()" class="demo-button">
<fa-icon [icon]="resetIcon"></fa-icon>
Reset
</button>
</div>
</div>
<div class="demo-item">
<h4>Non-Linear</h4>
<ui-stepper
[steps]="nonLinearSteps"
[clickable]="true"
[linear]="false"
(stepClick)="onStepClick('non-linear', $event)">
</ui-stepper>
</div>
</div>
</section>
<!-- Interactive Example -->
<section class="demo-section">
<h3>Interactive Example</h3>
<div class="interactive-example">
<ui-stepper
#interactiveStepper
[steps]="interactiveSteps"
[clickable]="true"
[linear]="false"
orientation="horizontal"
(stepClick)="onInteractiveStepClick($event)"
(stepChange)="onInteractiveStepChange($event)">
</ui-stepper>
<div class="demo-controls">
<button (click)="markCurrentCompleted()" class="demo-button">
Mark Current Completed
</button>
<button (click)="markCurrentError()" class="demo-button">
Mark Current Error
</button>
<button (click)="nextInteractiveStep()" class="demo-button">
<fa-icon [icon]="playIcon"></fa-icon>
Next
</button>
<button (click)="resetInteractiveStepper()" class="demo-button">
<fa-icon [icon]="resetIcon"></fa-icon>
Reset
</button>
</div>
<div class="step-info">
<p><strong>Current Step:</strong> {{ getCurrentStepInfo() }}</p>
<p><strong>Completed Steps:</strong> {{ getCompletedStepsCount() }}</p>
<p><strong>Last Action:</strong> {{ lastAction }}</p>
</div>
</div>
</section>
</div>
`,
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;
}
}

View File

@@ -1,7 +1,7 @@
import { Component, ChangeDetectionStrategy, TemplateRef, ViewChild } from '@angular/core'; import { Component, ChangeDetectionStrategy, TemplateRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { TableComponent } from '../../../../../ui-essentials/src/lib/components/data-display/table/table.component'; 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 { 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 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'; import { StatusBadgeComponent } from '../../../../../ui-essentials/src/lib/components/feedback/status-badge.component';

View File

@@ -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: `
<div style="padding: 2rem;">
<h2>Tag Input Component Showcase</h2>
<!-- Basic Tag Input Variants -->
<section style="margin-bottom: 3rem;">
<h3>Basic Variants</h3>
<h4>Outlined (Default)</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
placeholder="Add tags (press Enter, Tab, or comma to add)"
helpText="Separate tags with Enter, Tab, comma, semicolon, or pipe"
[class]="'demo-tag-input'"
(tagAdd)="handleTagAdd('outlined', $event)"
(tagRemove)="handleTagRemove('outlined', $event)"
(duplicateTag)="handleDuplicateTag('outlined', $event)"
(tagValidationError)="handleValidationError('outlined', $event)">
</ui-tag-input>
</div>
<h4>Filled</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
variant="filled"
placeholder="Add filled style tags"
helpText="This input uses the filled variant style"
[class]="'demo-tag-input'"
(tagAdd)="handleTagAdd('filled', $event)"
(tagRemove)="handleTagRemove('filled', $event)">
</ui-tag-input>
</div>
</section>
<!-- Size Variants -->
<section style="margin-bottom: 3rem;">
<h3>Size Variants</h3>
<h4>Small</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
size="sm"
placeholder="Small size tags"
helpText="Compact size for dense layouts"
[class]="'demo-tag-input'"
(tagAdd)="handleTagAdd('small', $event)"
(tagRemove)="handleTagRemove('small', $event)">
</ui-tag-input>
</div>
<h4>Medium (Default)</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
size="md"
placeholder="Medium size tags"
helpText="Standard size for most use cases"
[class]="'demo-tag-input'"
(tagAdd)="handleTagAdd('medium', $event)"
(tagRemove)="handleTagRemove('medium', $event)">
</ui-tag-input>
</div>
<h4>Large</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
size="lg"
placeholder="Large size tags"
helpText="Larger size for better accessibility"
[class]="'demo-tag-input'"
(tagAdd)="handleTagAdd('large', $event)"
(tagRemove)="handleTagRemove('large', $event)">
</ui-tag-input>
</div>
</section>
<!-- Configuration Options -->
<section style="margin-bottom: 3rem;">
<h3>Configuration Options</h3>
<h4>Max Tags (3)</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
placeholder="Maximum 3 tags allowed"
[maxTags]="3"
helpText="This input has a maximum of 3 tags"
[class]="'demo-tag-input'"
(maxTagsReached)="handleMaxTagsReached('max-tags')"
(tagAdd)="handleTagAdd('max-tags', $event)"
(tagRemove)="handleTagRemove('max-tags', $event)">
</ui-tag-input>
</div>
<h4>Max Tag Length (10)</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
placeholder="Max 10 characters per tag"
[maxTagLength]="10"
helpText="Each tag can be at most 10 characters long"
[class]="'demo-tag-input'"
(tagValidationError)="handleValidationError('max-length', $event)"
(tagAdd)="handleTagAdd('max-length', $event)"
(tagRemove)="handleTagRemove('max-length', $event)">
</ui-tag-input>
</div>
<h4>Allow Duplicates</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
placeholder="Duplicate tags allowed"
[allowDuplicates]="true"
helpText="This input allows duplicate tags"
[class]="'demo-tag-input'"
(tagAdd)="handleTagAdd('allow-duplicates', $event)"
(tagRemove)="handleTagRemove('allow-duplicates', $event)">
</ui-tag-input>
</div>
<h4>No Trimming</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
placeholder="Leading/trailing spaces preserved"
[trimTags]="false"
helpText="Tags preserve leading and trailing whitespace"
[class]="'demo-tag-input'"
(tagAdd)="handleTagAdd('no-trim', $event)"
(tagRemove)="handleTagRemove('no-trim', $event)">
</ui-tag-input>
</div>
</section>
<!-- States -->
<section style="margin-bottom: 3rem;">
<h3>States</h3>
<h4>Normal</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
placeholder="Normal interactive state"
helpText="Standard interactive state"
[class]="'demo-tag-input'"
(tagAdd)="handleTagAdd('normal', $event)"
(tagRemove)="handleTagRemove('normal', $event)">
</ui-tag-input>
</div>
<h4>Disabled</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
placeholder="Disabled state"
[disabled]="true"
helpText="This input is disabled"
[class]="'demo-tag-input'">
</ui-tag-input>
</div>
<h4>Read Only</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
placeholder="Read only state"
[readonly]="true"
helpText="This input is read-only - tags cannot be added or removed"
[class]="'demo-tag-input'">
</ui-tag-input>
</div>
<h4>With Error</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
placeholder="Input with error"
errorMessage="This field is required and must contain at least one tag"
[class]="'demo-tag-input'"
(tagAdd)="handleTagAdd('error', $event)"
(tagRemove)="handleTagRemove('error', $event)">
</ui-tag-input>
</div>
</section>
<!-- Form Integration -->
<section style="margin-bottom: 3rem;">
<h3>Form Integration</h3>
<form [formGroup]="demoForm" (ngSubmit)="handleFormSubmit()" style="max-width: 600px;">
<h4>Reactive Form Example</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
formControlName="tags"
placeholder="Required: Add at least 2 tags"
helpText="This field is required and uses reactive forms validation"
[class]="'demo-tag-input'"
[errorMessage]="getFormFieldError('tags')"
(tagAdd)="handleTagAdd('form', $event)"
(tagRemove)="handleTagRemove('form', $event)">
</ui-tag-input>
</div>
<h4>Skills (Optional)</h4>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
formControlName="skills"
placeholder="Add your skills"
[maxTags]="5"
helpText="Optional field - up to 5 skills"
[class]="'demo-tag-input'"
(tagAdd)="handleTagAdd('skills', $event)"
(tagRemove)="handleTagRemove('skills', $event)">
</ui-tag-input>
</div>
<div style="display: flex; gap: 1rem; align-items: center;">
<ui-button
type="submit"
variant="filled"
[disabled]="demoForm.invalid">
Submit Form
</ui-button>
<ui-button
type="button"
variant="tonal"
[icon]="faRefresh"
iconPosition="left"
(clicked)="resetForm()">
Reset
</ui-button>
</div>
@if (formSubmitted) {
<div style="margin-top: 1rem; padding: 1rem; background: #e8f5e8; border-radius: 4px; border-left: 4px solid #4caf50;">
<strong>Form Submitted!</strong><br>
<strong>Tags:</strong> {{ formData.tags?.join(', ') || 'None' }}<br>
<strong>Skills:</strong> {{ formData.skills?.join(', ') || 'None' }}
</div>
}
</form>
</section>
<!-- Programmatic Control -->
<section style="margin-bottom: 3rem;">
<h3>Programmatic Control</h3>
<div style="margin-bottom: 1.5rem;">
<ui-tag-input
#programmaticInput
placeholder="Controlled programmatically"
helpText="Use the buttons below to control this input"
[class]="'demo-tag-input'">
</ui-tag-input>
</div>
<div style="display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1rem;">
<ui-button
variant="outlined"
size="small"
[icon]="faPlus"
iconPosition="left"
(clicked)="addPredefinedTag(programmaticInput)">
Add Random Tag
</ui-button>
<ui-button
variant="outlined"
size="small"
(clicked)="clearProgrammaticInput(programmaticInput)">
Clear All
</ui-button>
<ui-button
variant="outlined"
size="small"
(clicked)="addMultipleTags(programmaticInput)">
Add Multiple
</ui-button>
<ui-button
variant="outlined"
size="small"
(clicked)="getTagsAsString(programmaticInput)">
Get as String
</ui-button>
</div>
</section>
<!-- Usage Examples -->
<section style="margin-bottom: 3rem;">
<h3>Usage Examples</h3>
<div style="background: #f8f9fa; padding: 1.5rem; border-radius: 8px; border-left: 4px solid #007bff;">
<h4>Basic Usage:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;ui-tag-input
placeholder="Add tags..."
(tagAdd)="onTagAdd($event)"
(tagRemove)="onTagRemove($event)"&gt;
&lt;/ui-tag-input&gt;</code></pre>
<h4>With Constraints:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;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)"&gt;
&lt;/ui-tag-input&gt;</code></pre>
<h4>Form Integration:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;form [formGroup]="myForm"&gt;
&lt;ui-tag-input
formControlName="tags"
placeholder="Required tags"
[errorMessage]="getFieldError('tags')"&gt;
&lt;/ui-tag-input&gt;
&lt;/form&gt;</code></pre>
<h4>Custom Separators:</h4>
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code>&lt;ui-tag-input
placeholder="Use space or dash to separate"
[separators]="[' ', '-']"
helpText="Tags separated by space or dash"&gt;
&lt;/ui-tag-input&gt;</code></pre>
</div>
</section>
<!-- Event Log -->
<section style="margin-bottom: 3rem;">
<h3>Event Log</h3>
@if (lastAction) {
<div style="margin-bottom: 1rem; padding: 1rem; background: #e3f2fd; border-radius: 4px;">
<strong>Last action:</strong> {{ lastAction }}
</div>
}
<div style="display: flex; gap: 1rem; flex-wrap: wrap;">
<ui-button variant="tonal" (clicked)="clearEventLog()">Clear Log</ui-button>
<ui-button variant="outlined" (clicked)="showAlert('Tag Input demo complete!')">Show Alert</ui-button>
</div>
</section>
</div>
`,
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<string[]>([], [
Validators.required,
this.minTagsValidator(2)
]),
skills: new FormControl<string[]>([])
});
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';
}
}

View File

@@ -195,7 +195,7 @@
} }
// Responsive Design // Responsive Design
@media (max-width: $semantic-breakpoint-md - 1) { @media (max-width: calc($semantic-breakpoint-md - 1px)) {
.demo-container { .demo-container {
padding: $semantic-spacing-layout-section-sm; 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 { .demo-container {
padding: $semantic-spacing-layout-section-xs; padding: $semantic-spacing-layout-section-xs;
} }

View File

@@ -2,7 +2,7 @@ import { Component } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faPlay, faStop, faRedo } from '@fortawesome/free-solid-svg-icons'; 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({ @Component({
selector: 'ui-toast-demo', selector: 'ui-toast-demo',

View File

@@ -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;
}
}

View File

@@ -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: `
<div class="demo-container">
<h2>Transfer List Demo</h2>
<!-- Basic Example -->
<section class="demo-section">
<h3>Basic Transfer List</h3>
<p class="demo-description">
Select items from the source list and transfer them to the target list using the control buttons.
</p>
<ui-transfer-list
[sourceItems]="basicSourceItems()"
[targetItems]="basicTargetItems()"
sourceTitle="Available Items"
targetTitle="Selected Items"
(sourceChange)="onBasicSourceChange($event)"
(targetChange)="onBasicTargetChange($event)"
(itemMove)="onItemMove($event)"
></ui-transfer-list>
</section>
<!-- Size Variants -->
<section class="demo-section">
<h3>Size Variants</h3>
<div class="demo-row demo-row--vertical">
<div class="demo-item">
<h4>Small</h4>
<ui-transfer-list
size="sm"
[sourceItems]="sizeSourceItems()"
[targetItems]="sizeTargetItems()"
sourceTitle="Small List"
targetTitle="Selected"
></ui-transfer-list>
</div>
<div class="demo-item">
<h4>Medium (Default)</h4>
<ui-transfer-list
size="md"
[sourceItems]="sizeSourceItems()"
[targetItems]="sizeTargetItems()"
sourceTitle="Medium List"
targetTitle="Selected"
></ui-transfer-list>
</div>
<div class="demo-item">
<h4>Large</h4>
<ui-transfer-list
size="lg"
[sourceItems]="sizeSourceItems()"
[targetItems]="sizeTargetItems()"
sourceTitle="Large List"
targetTitle="Selected"
></ui-transfer-list>
</div>
</div>
</section>
<!-- Configuration Options -->
<section class="demo-section">
<h3>Configuration Options</h3>
<div class="demo-controls">
<label>
<input type="checkbox" [checked]="showSearch()" (change)="toggleSearch()">
Show Search
</label>
<label>
<input type="checkbox" [checked]="showSelectAll()" (change)="toggleSelectAll()">
Show Select All
</label>
<label>
<input type="checkbox" [checked]="showCheckboxes()" (change)="toggleCheckboxes()">
Show Checkboxes
</label>
<label>
<input type="checkbox" [checked]="showMoveAll()" (change)="toggleMoveAll()">
Show Move All Buttons
</label>
<label>
<input type="checkbox" [checked]="isDisabled()" (change)="toggleDisabled()">
Disabled
</label>
</div>
<ui-transfer-list
[sourceItems]="configSourceItems()"
[targetItems]="configTargetItems()"
[showSearch]="showSearch()"
[showSelectAll]="showSelectAll()"
[showCheckboxes]="showCheckboxes()"
[showMoveAll]="showMoveAll()"
[disabled]="isDisabled()"
sourceTitle="Configurable Source"
targetTitle="Configurable Target"
searchPlaceholder="Filter items..."
(sourceChange)="onConfigSourceChange($event)"
(targetChange)="onConfigTargetChange($event)"
></ui-transfer-list>
</section>
<!-- With Disabled Items -->
<section class="demo-section">
<h3>With Disabled Items</h3>
<p class="demo-description">
Some items can be disabled and cannot be selected or transferred.
</p>
<ui-transfer-list
[sourceItems]="disabledSourceItems()"
[targetItems]="disabledTargetItems()"
sourceTitle="Mixed Items"
targetTitle="Available Items"
(sourceChange)="onDisabledSourceChange($event)"
(targetChange)="onDisabledTargetChange($event)"
></ui-transfer-list>
</section>
<!-- Large Dataset -->
<section class="demo-section">
<h3>Large Dataset</h3>
<p class="demo-description">
Transfer list with a large number of items to test performance and search functionality.
</p>
<ui-transfer-list
[sourceItems]="largeSourceItems()"
[targetItems]="largeTargetItems()"
sourceTitle="Large Dataset ({{ largeSourceItems().length }} items)"
targetTitle="Selected Items"
searchPlaceholder="Search in {{ largeSourceItems().length }} items..."
(sourceChange)="onLargeSourceChange($event)"
(targetChange)="onLargeTargetChange($event)"
></ui-transfer-list>
</section>
<!-- Custom Labels -->
<section class="demo-section">
<h3>Custom Labels & Accessibility</h3>
<ui-transfer-list
[sourceItems]="customSourceItems()"
[targetItems]="customTargetItems()"
sourceTitle="Available Permissions"
targetTitle="Granted Permissions"
searchPlaceholder="Search permissions..."
ariaLabel="User permission management"
(sourceChange)="onCustomSourceChange($event)"
(targetChange)="onCustomTargetChange($event)"
></ui-transfer-list>
</section>
<!-- Event Log -->
<section class="demo-section">
<h3>Event Log</h3>
<div class="demo-event-log">
@if (eventLog().length === 0) {
<p class="demo-no-events">No events logged yet. Interact with the transfer lists above to see events.</p>
} @else {
@for (event of eventLog(); track $index) {
<div class="demo-event">
<strong>{{ event.type }}:</strong> {{ event.message }}
<small class="demo-event-time">{{ event.timestamp | date:'short' }}</small>
</div>
}
}
@if (eventLog().length > 0) {
<button type="button" class="demo-clear-events" (click)="clearEventLog()">
Clear Log
</button>
}
</div>
</section>
</div>
`,
styleUrl: './transfer-list-demo.component.scss'
})
export class TransferListDemoComponent {
// Basic example data
basicSourceItems = signal<TransferListItem[]>([
{ 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<TransferListItem[]>([
{ id: 9, label: 'Orange' },
{ id: 10, label: 'Pear' }
]);
// Size variant data
sizeSourceItems = signal<TransferListItem[]>([
{ id: 'size1', label: 'Item 1' },
{ id: 'size2', label: 'Item 2' },
{ id: 'size3', label: 'Item 3' }
]);
sizeTargetItems = signal<TransferListItem[]>([
{ id: 'size4', label: 'Item 4' }
]);
// Configuration example data
configSourceItems = signal<TransferListItem[]>([
{ 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<TransferListItem[]>([]);
// Configuration toggles
showSearch = signal(true);
showSelectAll = signal(true);
showCheckboxes = signal(true);
showMoveAll = signal(true);
isDisabled = signal(false);
// Disabled items example
disabledSourceItems = signal<TransferListItem[]>([
{ 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<TransferListItem[]>([]);
// Large dataset
largeSourceItems = signal<TransferListItem[]>(this.generateLargeDataset());
largeTargetItems = signal<TransferListItem[]>([]);
// Custom labels example
customSourceItems = signal<TransferListItem[]>([
{ 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<TransferListItem[]>([]);
// Event logging
eventLog = signal<Array<{ type: string; message: string; timestamp: Date }>>([]);
// 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([]);
}
}

View File

@@ -10,7 +10,7 @@ import {
faGripVertical, faArrowsAlt, faBoxOpen, faChevronLeft, faSpinner, faExclamationTriangle, faGripVertical, faArrowsAlt, faBoxOpen, faChevronLeft, faSpinner, faExclamationTriangle,
faCloudUploadAlt, faFileText, faListAlt, faCircle, faExpandArrowsAlt, faCircleNotch, faSliders, faCloudUploadAlt, faFileText, faListAlt, faCircle, faExpandArrowsAlt, faCircleNotch, faSliders,
faMinus, faInfoCircle, faChevronDown, faCaretUp, faExclamationCircle, faSitemap, faStream, 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'; } from '@fortawesome/free-solid-svg-icons';
import { DemoRoutes } from '../../demos'; import { DemoRoutes } from '../../demos';
import { ScrollContainerComponent } from '../../../../../ui-essentials/src/lib/layouts/scroll-container.component'; import { ScrollContainerComponent } from '../../../../../ui-essentials/src/lib/layouts/scroll-container.component';
@@ -107,6 +107,7 @@ export class DashboardComponent {
faPalette = faPalette; faPalette = faPalette;
faExchangeAlt = faExchangeAlt; faExchangeAlt = faExchangeAlt;
faTools = faTools; faTools = faTools;
faHeart = faHeart;
menuItems: any = [] menuItems: any = []
@@ -153,7 +154,8 @@ export class DashboardComponent {
this.createChildItem("file-upload", "File Upload", this.faCloudUploadAlt), this.createChildItem("file-upload", "File Upload", this.faCloudUploadAlt),
this.createChildItem("form-field", "Form Field", this.faFileText), this.createChildItem("form-field", "Form Field", this.faFileText),
this.createChildItem("range-slider", "Range Slider", this.faSliders), 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); this.addMenuItem("forms", "Forms", this.faEdit, formsChildren);
@@ -196,6 +198,7 @@ export class DashboardComponent {
// Actions category // Actions category
const actionsChildren = [ const actionsChildren = [
this.createChildItem("buttons", "Buttons", this.faMousePointer), this.createChildItem("buttons", "Buttons", this.faMousePointer),
this.createChildItem("icon-button", "Icon Buttons", this.faHeart),
this.createChildItem("fab-menu", "FAB Menu", this.faEllipsisV), this.createChildItem("fab-menu", "FAB Menu", this.faEllipsisV),
this.createChildItem("split-button", "Split Button", this.faCut) this.createChildItem("split-button", "Split Button", this.faCut)
]; ];

View File

@@ -1,14 +1,13 @@
import { Component, EventEmitter, Output, Input, OnInit } from '@angular/core'; import { Component, EventEmitter, Output, Input, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; import {
import { MenuItemComponent } from '../../../../../ui-essentials/src/lib/components/navigation/menu/menu-item.component'; MenuContainerComponent,
import { faChevronRight, faChevronDown } from '@fortawesome/free-solid-svg-icons'; MenuSubmenuComponent,
MenuItemComponent,
MenuItemData
} from '../../../../../ui-essentials/src/lib/components/navigation/menu';
export interface SidebarMenuItem { export interface SidebarMenuItem extends MenuItemData {
id: string;
label: string;
icon?: any; // FontAwesome icon definition
active: boolean;
children?: SidebarMenuItem[]; children?: SidebarMenuItem[];
expanded?: boolean; expanded?: boolean;
isParent?: boolean; isParent?: boolean;
@@ -19,48 +18,49 @@ export interface SidebarMenuItem {
standalone: true, standalone: true,
imports: [ imports: [
CommonModule, CommonModule,
FontAwesomeModule, MenuContainerComponent,
MenuSubmenuComponent,
MenuItemComponent MenuItemComponent
], ],
template: ` template: `
<div class="sidebar-container"> <ui-menu-container
[elevation]="'none'"
[spacing]="'none'"
[rounded]="false"
class="sidebar-container"
>
@for(item of data; track item.id) { @for(item of data; track item.id) {
<div class="menu-item-wrapper"> @if(item.isParent && item.children) {
@if(item.isParent) { <ui-menu-submenu
<div class="parent-menu-item" (click)="toggleExpanded(item)"> [parentItem]="getMenuItemData(item)"
<div class="parent-content"> [size]="'md'"
<fa-icon [icon]="item.icon" class="parent-icon"></fa-icon> [iconPosition]="'left'"
<span class="parent-label">{{ item.label }}</span> [isOpen]="item.expanded || false"
</div> [submenuElevation]="'none'"
<fa-icon [submenuSpacing]="'xs'"
[icon]="item.expanded ? faChevronDown : faChevronRight" (submenuToggled)="onSubmenuToggled($event)"
class="chevron-icon"> (parentItemClick)="handleMenuClick($event.id || '')"
</fa-icon> >
</div> @for(child of item.children; track child.id) {
@if(item.expanded && item.children) { <ui-menu-item
<div class="children-container"> [data]="getMenuItemData(child)"
@for(child of item.children; track child.id) { [size]="'sm'"
<ui-menu-item [iconPosition]="'left'"
[data]="{ label: child.label, icon: child.icon, active: child.active }" [indent]="true"
[size]="'md'" (itemClick)="handleMenuClick($event.id || '')"
iconPosition="left" />
(itemClick)="handleMenuClick(child.id)"
class="child-menu-item"
/>
}
</div>
} }
} @else { </ui-menu-submenu>
<ui-menu-item } @else {
[data]="{ label: item.label, icon: item.icon, active: item.active }" <ui-menu-item
[size]="'md'" [data]="getMenuItemData(item)"
iconPosition="left" [size]="'md'"
(itemClick)="handleMenuClick(item.id)" [iconPosition]="'left'"
/> (itemClick)="handleMenuClick($event.id || '')"
} />
</div> }
} }
</div> </ui-menu-container>
`, `,
styleUrls: ['./dashboard.sidebar.component.scss'] styleUrls: ['./dashboard.sidebar.component.scss']
@@ -71,9 +71,6 @@ export class DashboardSidebarComponent implements OnInit {
@Input() set active(value: string) { this.setActive(value) } @Input() set active(value: string) { this.setActive(value) }
@Output() selected = new EventEmitter<string>(); @Output() selected = new EventEmitter<string>();
faChevronRight = faChevronRight;
faChevronDown = faChevronDown;
handleMenuClick(id: string): void { handleMenuClick(id: string): void {
this.setActive(id); this.setActive(id);
this.selected.emit(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 { ngOnInit(): void {
} }

View File

@@ -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;
}
}
}

View File

@@ -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: `
<div
class="ui-fab-menu"
[class.ui-fab-menu--{{size}}]="size"
[class.ui-fab-menu--{{variant}}]="variant"
[class.ui-fab-menu--{{position}}]="position"
[class.ui-fab-menu--{{direction}}]="actualDirection"
[class.ui-fab-menu--open]="isOpen"
[class.ui-fab-menu--disabled]="disabled"
[class.ui-fab-menu--animating]="isAnimating">
<!-- Menu Items -->
@if (isOpen) {
<div class="ui-fab-menu__items" role="menu">
@for (item of menuItems; track item.id) {
<button
type="button"
class="ui-fab-menu__item"
[class.ui-fab-menu__item--{{item.variant || variant}}]="item.variant || variant"
[class.ui-fab-menu__item--disabled]="item.disabled"
[disabled]="item.disabled || disabled"
[attr.aria-label]="item.label"
role="menuitem"
[tabindex]="isOpen ? 0 : -1"
(click)="handleItemClick(item, $event)"
(keydown)="handleItemKeydown(item, $event)">
@if (item.icon) {
<span class="ui-fab-menu__item-icon" [innerHTML]="item.icon"></span>
}
<span class="ui-fab-menu__item-label">{{ item.label }}</span>
<div class="ui-fab-menu__item-ripple"></div>
</button>
}
</div>
}
<!-- Main FAB Button -->
<button
type="button"
class="ui-fab-menu__trigger"
[class.ui-fab-menu__trigger--{{size}}]="size"
[class.ui-fab-menu__trigger--{{variant}}]="variant"
[class.ui-fab-menu__trigger--active]="isOpen"
[disabled]="disabled"
[attr.aria-expanded]="isOpen"
[attr.aria-haspopup]="true"
[attr.aria-label]="triggerLabel"
role="button"
(click)="toggle($event)"
(keydown)="handleTriggerKeydown($event)">
@if (isOpen && closeIcon) {
<span class="ui-fab-menu__trigger-icon ui-fab-menu__trigger-icon--close" [innerHTML]="closeIcon"></span>
} @else if (triggerIcon) {
<span class="ui-fab-menu__trigger-icon ui-fab-menu__trigger-icon--open" [innerHTML]="triggerIcon"></span>
}
<ng-content></ng-content>
<div class="ui-fab-menu__trigger-ripple"></div>
</button>
<!-- Backdrop -->
@if (isOpen && backdrop) {
<div
class="ui-fab-menu__backdrop"
(click)="close()"
role="presentation"></div>
}
</div>
`,
styleUrl: './fab-menu.component.scss'
})
export class FabMenuComponent {
@Input() size: FabMenuSize = 'md';
@Input() variant: FabMenuVariant = 'primary';
@Input() position: FabMenuPosition = 'bottom-right';
@Input() direction: FabMenuDirection = 'auto';
@Input() menuItems: FabMenuItem[] = [];
@Input() disabled = false;
@Input() triggerIcon?: string;
@Input() closeIcon?: string;
@Input() triggerLabel = 'Open menu';
@Input() backdrop = true;
@Input() closeOnItemClick = true;
@Input() closeOnOutsideClick = true;
@Output() opened = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
@Output() itemClicked = new EventEmitter<{item: FabMenuItem, event: Event}>();
private elementRef = inject(ElementRef);
isOpen = false;
isAnimating = false;
get actualDirection(): FabMenuDirection {
if (this.direction !== 'auto') return this.direction;
// Auto-determine direction based on position
switch (this.position) {
case 'bottom-right':
case 'bottom-left':
return 'up';
case 'top-right':
case 'top-left':
return 'down';
default:
return 'up';
}
}
@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();
}
}

View File

@@ -0,0 +1 @@
export * from './fab-menu.component';

View File

@@ -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;
}
}

View File

@@ -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: `
<button
[class]="buttonClasses"
[disabled]="disabled || loading"
[type]="type"
[attr.aria-label]="ariaLabel"
[attr.aria-pressed]="pressed"
[attr.title]="title"
(click)="handleClick($event)"
>
@if (loading) {
<div class="icon-button-loader">
<div class="icon-button-spinner"></div>
</div>
} @else {
<fa-icon [icon]="icon" class="icon-button-icon"></fa-icon>
}
</button>
`,
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<Event>();
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);
}
}
}

View File

@@ -0,0 +1 @@
export * from './icon-button.component';

View File

@@ -5,3 +5,4 @@ export * from './fab.component';
export * from './fab-menu'; export * from './fab-menu';
export * from './simple-button.component'; export * from './simple-button.component';
export * from './split-button'; export * from './split-button';
export * from './icon-button';

View File

@@ -11,20 +11,32 @@
// Size variants // Size variants
&--small { &--small {
height: $semantic-spacing-9; // 2.25rem 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 { &--medium {
height: $semantic-spacing-11; // 2.75rem 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 { &--large {
height: $semantic-spacing-12; // 3rem 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 // Full width variant
@@ -49,10 +61,17 @@
text-decoration: none; text-decoration: none;
outline: none; outline: none;
flex: 1; flex: 1;
height: 100%; // Match the container height
// Default colors (filled variant)
background-color: $semantic-color-interactive-primary;
color: $semantic-color-on-primary;
// Typography // Typography
font-family: $semantic-typography-font-family-sans; font-family: $semantic-typography-font-family-sans;
font-size: $semantic-typography-font-size-md; // Default medium size
font-weight: $semantic-typography-font-weight-medium; font-weight: $semantic-typography-font-weight-medium;
line-height: $semantic-typography-line-height-normal; // Default medium line-height
text-align: center; text-align: center;
text-transform: none; text-transform: none;
letter-spacing: $semantic-typography-letter-spacing-normal; letter-spacing: $semantic-typography-letter-spacing-normal;
@@ -66,6 +85,23 @@
// Transitions // Transitions
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 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 // Size-specific padding
.ui-split-button--small & { .ui-split-button--small & {
padding: 0 $semantic-spacing-3; // 0.75rem padding: 0 $semantic-spacing-3; // 0.75rem
@@ -90,21 +126,19 @@
outline: none; outline: none;
position: relative; position: relative;
// Size-specific dimensions // Default colors (filled variant)
.ui-split-button--small & { background-color: $semantic-color-interactive-primary;
width: $semantic-spacing-9; // 2.25rem color: $semantic-color-on-primary;
padding: 0 $semantic-spacing-2; // 0.5rem
}
.ui-split-button--medium & { // Typography
width: $semantic-spacing-11; // 2.75rem font-family: $semantic-typography-font-family-sans;
padding: 0 $semantic-spacing-3; // 0.75rem 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 & { // Default size (medium) - defaults only
width: $semantic-spacing-12; // 3rem width: $semantic-spacing-9; // Default to medium size
padding: 0 $semantic-spacing-4; // 1rem padding: 0 $semantic-spacing-2-5; // Default medium padding
}
// Interaction states // Interaction states
user-select: none; user-select: none;
@@ -114,6 +148,23 @@
// Transitions // Transitions
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1); 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 // Divider between buttons
&::before { &::before {
content: ''; 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 wrapper
&__content { &__content {
display: flex; display: flex;
@@ -182,7 +252,7 @@
&__arrow { &__arrow {
display: inline-flex; display: inline-flex;
align-items: center; 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); transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
} }

View File

@@ -24,11 +24,7 @@ export interface SplitButtonMenuItem {
template: ` template: `
<div <div
class="ui-split-button" class="ui-split-button"
[class.ui-split-button--{{size}}]="size" [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' : '')">
[class.ui-split-button--{{variant}}]="variant"
[class.ui-split-button--disabled]="disabled"
[class.ui-split-button--loading]="loading"
[class.ui-split-button--full-width]="fullWidth">
<!-- Primary button --> <!-- Primary button -->
<button <button

View File

@@ -11,6 +11,17 @@
// Base styles // Base styles
font-family: $semantic-typography-font-family-sans; font-family: $semantic-typography-font-family-sans;
// Default styles (md variant as default)
.ui-accordion__header {
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
font-size: $semantic-typography-font-size-md;
}
.ui-accordion__content {
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
font-size: $semantic-typography-font-size-md;
}
// Size variants // Size variants
&--sm { &--sm {
.ui-accordion__header { .ui-accordion__header {

View File

@@ -11,6 +11,7 @@ export * from './progress';
export * from './table'; export * from './table';
export * from './timeline'; export * from './timeline';
export * from './tooltip'; export * from './tooltip';
export * from './transfer-list';
export * from './tree-view'; export * from './tree-view';
// Selectively export from feedback to avoid ProgressBarComponent conflict // Selectively export from feedback to avoid ProgressBarComponent conflict
export { StatusBadgeComponent } from '../feedback'; export { StatusBadgeComponent } from '../feedback';

View File

@@ -0,0 +1,480 @@
@use "../../../../../../shared-ui/src/styles/semantic" as *;
.ui-enhanced-table-container {
position: relative;
width: 100%;
background: $semantic-color-surface-primary;
border-radius: $semantic-border-card-radius;
box-shadow: $semantic-shadow-elevation-1;
overflow: hidden;
// Variants
&--striped {
.ui-enhanced-table__row:nth-child(even),
.ui-enhanced-table__virtual-row:nth-child(even) {
background: $semantic-color-surface-secondary;
}
}
&--bordered {
border: $semantic-border-width-1 solid $semantic-color-border-primary;
.ui-enhanced-table__header-cell,
.ui-enhanced-table__cell,
.ui-enhanced-table__virtual-cell {
border-right: $semantic-border-width-1 solid $semantic-color-border-subtle;
}
}
&--minimal {
box-shadow: none;
border-radius: 0;
.ui-enhanced-table__header {
border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle;
}
}
// Size variants
&--compact {
.ui-enhanced-table__header-cell,
.ui-enhanced-table__cell,
.ui-enhanced-table__virtual-cell {
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
font-size: map-get($semantic-typography-body-small, font-size);
line-height: map-get($semantic-typography-body-small, line-height);
}
}
&--comfortable {
.ui-enhanced-table__header-cell,
.ui-enhanced-table__cell,
.ui-enhanced-table__virtual-cell {
padding: $semantic-spacing-component-lg $semantic-spacing-component-md;
font-size: map-get($semantic-typography-body-large, font-size);
line-height: map-get($semantic-typography-body-large, line-height);
}
}
// States
&--sticky-header {
.ui-enhanced-table__header {
position: sticky;
top: 0;
z-index: $semantic-z-index-dropdown;
background: $semantic-color-surface-elevated;
box-shadow: $semantic-shadow-elevation-1;
}
}
&--loading {
pointer-events: none;
.ui-enhanced-table__loading-overlay {
opacity: 1;
visibility: visible;
}
}
&--virtual {
.ui-enhanced-table__scroll-container {
overflow-y: auto;
}
}
}
// Filter Row
.ui-enhanced-table__filter-row {
display: flex;
background: $semantic-color-surface-secondary;
border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle;
padding: $semantic-spacing-component-sm 0;
gap: $semantic-spacing-component-xs;
}
.ui-enhanced-table__filter-cell {
padding: 0 $semantic-spacing-component-sm;
flex-shrink: 0;
}
.ui-enhanced-table__filter-input,
.ui-enhanced-table__filter-select {
width: 100%;
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-small, font-size);
font-family: map-get($semantic-typography-body-small, font-family);
line-height: map-get($semantic-typography-body-small, line-height);
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
&:focus {
outline: none;
border-color: $semantic-color-focus;
box-shadow: 0 0 0 2px $semantic-color-focus;
}
&::placeholder {
color: $semantic-color-text-tertiary;
}
}
// Scroll Container
.ui-enhanced-table__scroll-container {
position: relative;
overflow-x: auto;
max-height: 600px;
}
// Header
.ui-enhanced-table__header {
background: $semantic-color-surface-elevated;
border-bottom: $semantic-border-width-2 solid $semantic-color-border-primary;
}
.ui-enhanced-table__header-row {
display: flex;
min-width: 100%;
}
.ui-enhanced-table__header-cell {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
padding: $semantic-spacing-component-md;
background: $semantic-color-surface-elevated;
border-right: $semantic-border-width-1 solid $semantic-color-border-subtle;
font-weight: $semantic-typography-font-weight-semibold;
font-size: map-get($semantic-typography-body-medium, font-size);
font-family: map-get($semantic-typography-body-medium, font-family);
line-height: map-get($semantic-typography-body-medium, line-height);
color: $semantic-color-text-primary;
flex-shrink: 0;
user-select: none;
&--sortable {
cursor: pointer;
transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease;
&:hover {
background: $semantic-color-surface-secondary;
}
}
&--sorted {
background: $semantic-color-surface-secondary;
}
&--draggable {
cursor: grab;
&:active {
cursor: grabbing;
}
}
&:last-child {
border-right: none;
}
}
.ui-enhanced-table__header-content {
display: flex;
align-items: center;
gap: $semantic-spacing-component-xs;
flex: 1;
}
.ui-enhanced-table__header-text {
font-weight: $semantic-typography-font-weight-semibold;
}
.ui-enhanced-table__sort-indicators {
display: flex;
flex-direction: column;
gap: 2px;
}
.ui-enhanced-table__sort-indicator {
display: inline-flex;
align-items: center;
font-size: 12px;
color: $semantic-color-primary;
font-weight: $semantic-typography-font-weight-bold;
sup {
font-size: 8px;
margin-left: 2px;
}
&[data-direction="asc"] {
color: $semantic-color-success;
}
&[data-direction="desc"] {
color: $semantic-color-danger;
}
}
// Resize Handle
.ui-enhanced-table__resize-handle {
position: absolute;
top: 0;
right: 0;
width: 4px;
height: 100%;
background: transparent;
cursor: col-resize;
z-index: 1;
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2px;
height: 20px;
background: $semantic-color-border-secondary;
opacity: 0;
transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease;
}
&:hover::before {
opacity: 1;
}
}
// Virtual Viewport
.ui-enhanced-table__virtual-viewport {
position: relative;
overflow-y: auto;
}
// Rows (Traditional and Virtual)
.ui-enhanced-table__body {
background: $semantic-color-surface-primary;
}
.ui-enhanced-table__row,
.ui-enhanced-table__virtual-row {
display: flex;
min-width: 100%;
transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease;
&--hover:hover {
background: $semantic-color-surface-secondary;
}
&--selected {
background: rgba($semantic-color-primary, 0.1);
border-left: 3px solid $semantic-color-primary;
}
&--striped {
background: $semantic-color-surface-secondary;
}
}
// Cells
.ui-enhanced-table__cell,
.ui-enhanced-table__virtual-cell {
display: flex;
align-items: center;
padding: $semantic-spacing-component-md;
border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle;
font-size: map-get($semantic-typography-body-medium, font-size);
font-family: map-get($semantic-typography-body-medium, font-family);
line-height: map-get($semantic-typography-body-medium, line-height);
color: $semantic-color-text-primary;
flex-shrink: 0;
overflow: hidden;
}
.ui-enhanced-table__cell-content {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Empty State
.ui-enhanced-table__empty-row {
display: flex;
justify-content: center;
padding: $semantic-spacing-layout-section-lg;
}
.ui-enhanced-table__empty-cell {
text-align: center;
}
.ui-enhanced-table__empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: $semantic-spacing-component-md;
}
.ui-enhanced-table__empty-text {
font-size: map-get($semantic-typography-body-large, font-size);
font-family: map-get($semantic-typography-body-large, font-family);
color: $semantic-color-text-tertiary;
}
// Loading Overlay
.ui-enhanced-table__loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba($semantic-color-backdrop, 0.5);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $semantic-spacing-component-md;
opacity: 0;
visibility: hidden;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
z-index: $semantic-z-index-overlay;
backdrop-filter: blur(2px);
}
.ui-enhanced-table__loading-spinner {
width: 32px;
height: 32px;
border: 3px solid $semantic-color-border-subtle;
border-top: 3px solid $semantic-color-primary;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.ui-enhanced-table__loading-text {
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;
font-weight: $semantic-typography-font-weight-medium;
}
// Column Reorder Ghost
.ui-enhanced-table__column-ghost {
position: fixed;
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
background: $semantic-color-primary;
color: $semantic-color-on-primary;
border-radius: $semantic-border-input-radius;
font-size: map-get($semantic-typography-body-small, font-size);
font-family: map-get($semantic-typography-body-small, font-family);
font-weight: $semantic-typography-font-weight-medium;
box-shadow: $semantic-shadow-elevation-3;
pointer-events: none;
z-index: $semantic-z-index-modal;
transform: translate(-50%, -100%);
opacity: 0.9;
}
// Animations
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// Responsive Design
@media (max-width: 768px) {
.ui-enhanced-table-container {
font-size: map-get($semantic-typography-body-small, font-size);
.ui-enhanced-table__header-cell,
.ui-enhanced-table__cell,
.ui-enhanced-table__virtual-cell {
padding: $semantic-spacing-component-sm;
}
.ui-enhanced-table__filter-row {
padding: $semantic-spacing-component-xs 0;
}
.ui-enhanced-table__resize-handle {
width: 8px; // Larger touch target
}
}
.ui-enhanced-table__scroll-container {
overflow-x: scroll;
-webkit-overflow-scrolling: touch;
}
}
// Accessibility
@media (prefers-reduced-motion: reduce) {
.ui-enhanced-table__header-cell,
.ui-enhanced-table__row,
.ui-enhanced-table__virtual-row,
.ui-enhanced-table__cell,
.ui-enhanced-table__virtual-cell,
.ui-enhanced-table__filter-input,
.ui-enhanced-table__filter-select,
.ui-enhanced-table__loading-overlay,
.ui-enhanced-table__resize-handle::before {
transition-duration: 0ms;
animation-duration: 0ms;
}
.ui-enhanced-table__loading-spinner {
animation: none;
}
}
// High contrast mode
@media (prefers-contrast: high) {
.ui-enhanced-table-container {
border: $semantic-border-width-2 solid $semantic-color-text-primary;
}
.ui-enhanced-table__header-cell,
.ui-enhanced-table__cell,
.ui-enhanced-table__virtual-cell {
border-color: $semantic-color-text-primary;
}
.ui-enhanced-table__filter-input,
.ui-enhanced-table__filter-select {
border-color: $semantic-color-text-primary;
}
}
// Print styles
@media print {
.ui-enhanced-table-container {
box-shadow: none;
border: $semantic-border-width-1 solid $semantic-color-text-primary;
}
.ui-enhanced-table__filter-row,
.ui-enhanced-table__loading-overlay,
.ui-enhanced-table__resize-handle,
.ui-enhanced-table__column-ghost {
display: none !important;
}
.ui-enhanced-table__header {
background: white !important;
}
.ui-enhanced-table__row,
.ui-enhanced-table__virtual-row {
break-inside: avoid;
}
}
// Focus management for keyboard navigation
.ui-enhanced-table__header-cell:focus-visible,
.ui-enhanced-table__row:focus-visible,
.ui-enhanced-table__virtual-row:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: -2px;
}

View File

@@ -0,0 +1,833 @@
import {
Component,
Input,
Output,
EventEmitter,
ChangeDetectionStrategy,
ViewEncapsulation,
ContentChild,
TemplateRef,
ViewChild,
ElementRef,
OnInit,
OnDestroy,
AfterViewInit,
HostListener,
signal,
computed
} from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
export type EnhancedTableVariant = 'default' | 'striped' | 'bordered' | 'minimal';
export type EnhancedTableSize = 'compact' | 'default' | 'comfortable';
export type EnhancedTableColumnAlign = 'left' | 'center' | 'right';
export type FilterOperator = 'equals' | 'contains' | 'startsWith' | 'endsWith' | 'gt' | 'gte' | 'lt' | 'lte' | 'range' | 'in' | 'notIn';
export interface EnhancedTableColumn {
key: string;
label: string;
sortable?: boolean;
filterable?: boolean;
resizable?: boolean;
draggable?: boolean;
align?: EnhancedTableColumnAlign;
width?: number;
minWidth?: number;
maxWidth?: number;
sticky?: boolean;
filterType?: 'text' | 'number' | 'date' | 'select' | 'multi-select';
filterOptions?: Array<{label: string, value: any}>;
hidden?: boolean;
}
export interface TableFilter {
column: string;
operator: FilterOperator;
value: any;
value2?: any; // For range filters
}
export interface TableSort {
column: string;
direction: 'asc' | 'desc';
priority: number; // For multi-column sorting
}
export interface EnhancedTableSortEvent {
sorting: TableSort[];
}
export interface EnhancedTableFilterEvent {
filters: TableFilter[];
}
export interface EnhancedTableColumnResizeEvent {
column: string;
width: number;
columnWidths: Record<string, number>;
}
export interface EnhancedTableColumnReorderEvent {
fromIndex: number;
toIndex: number;
columnOrder: string[];
}
export interface VirtualScrollConfig {
enabled: boolean;
itemHeight: number;
buffer?: number;
trackBy?: (index: number, item: any) => any;
}
@Component({
selector: 'ui-enhanced-table',
standalone: true,
imports: [CommonModule, FormsModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div [class]="tableContainerClasses" #tableContainer>
<!-- Filter Row (Optional) -->
@if (showFilterRow) {
<div class="ui-enhanced-table__filter-row">
@for (column of visibleColumns(); track column.key) {
@if (column.filterable) {
<div class="ui-enhanced-table__filter-cell" [style.width.px]="getColumnWidth(column.key)">
@switch (column.filterType || 'text') {
@case ('text') {
<input
type="text"
class="ui-enhanced-table__filter-input"
[placeholder]="'Filter ' + column.label"
[value]="getFilterValue(column.key)"
(input)="updateFilter(column.key, 'contains', $any($event.target).value)"
/>
}
@case ('number') {
<input
type="number"
class="ui-enhanced-table__filter-input"
[placeholder]="'Filter ' + column.label"
[value]="getFilterValue(column.key)"
(input)="updateFilter(column.key, 'equals', $any($event.target).value)"
/>
}
@case ('select') {
<select
class="ui-enhanced-table__filter-select"
[value]="getFilterValue(column.key)"
(change)="updateFilter(column.key, 'equals', $any($event.target).value)"
>
<option value="">All</option>
@for (option of column.filterOptions || []; track option.value) {
<option [value]="option.value">{{ option.label }}</option>
}
</select>
}
}
</div>
} @else {
<div class="ui-enhanced-table__filter-cell" [style.width.px]="getColumnWidth(column.key)"></div>
}
}
</div>
}
<!-- Virtual Scroll Container -->
<div class="ui-enhanced-table__scroll-container" #scrollContainer>
<!-- Header -->
<div class="ui-enhanced-table__header" [class.ui-enhanced-table__header--sticky]="stickyHeader">
<div class="ui-enhanced-table__header-row">
@for (column of visibleColumns(); track column.key; let colIndex = $index) {
<div
class="ui-enhanced-table__header-cell"
[class.ui-enhanced-table__header-cell--sortable]="column.sortable"
[class.ui-enhanced-table__header-cell--sorted]="getSortInfo(column.key).active"
[class.ui-enhanced-table__header-cell--draggable]="column.draggable"
[style.width.px]="getColumnWidth(column.key)"
[style.text-align]="column.align || 'left'"
[attr.data-column]="column.key"
[attr.aria-sort]="getSortAttribute(column.key)"
(click)="handleSort(column, $event)"
(mousedown)="startColumnReorder($event, colIndex)"
>
<div class="ui-enhanced-table__header-content">
<span class="ui-enhanced-table__header-text">{{ column.label }}</span>
<!-- Sort indicators -->
@if (column.sortable) {
<div class="ui-enhanced-table__sort-indicators">
@for (sort of getCurrentSorting(column.key); track sort.priority) {
<span
class="ui-enhanced-table__sort-indicator"
[attr.data-direction]="sort.direction"
[attr.data-priority]="sort.priority"
>
@if (sort.direction === 'asc') {
} @else {
}
@if (sorting().length > 1) {
<sup>{{ sort.priority + 1 }}</sup>
}
</span>
}
</div>
}
</div>
<!-- Resize handle -->
@if (column.resizable) {
<div
class="ui-enhanced-table__resize-handle"
(mousedown)="startColumnResize($event, column.key)"
(dblclick)="autoSizeColumn(column.key)"
></div>
}
</div>
}
</div>
</div>
<!-- Virtual Scroll Viewport -->
@if (virtualScrolling.enabled) {
<div
class="ui-enhanced-table__virtual-viewport"
[style.height.px]="virtualViewportHeight()"
(scroll)="onVirtualScroll($event)"
>
<!-- Spacer before visible items -->
@if (virtualStartOffset() > 0) {
<div [style.height.px]="virtualStartOffset()"></div>
}
<!-- Visible rows -->
@for (row of virtualRows(); track virtualScrolling.trackBy ? virtualScrolling.trackBy($index, row) : $index; let rowIndex = $index) {
<div
class="ui-enhanced-table__virtual-row"
[class.ui-enhanced-table__virtual-row--selected]="selectedRows.has(row)"
[class.ui-enhanced-table__virtual-row--hover]="hoverable"
[style.height.px]="virtualScrolling.itemHeight"
(click)="handleRowClick(row, virtualStartIndex() + rowIndex)"
>
@for (column of visibleColumns(); track column.key) {
<div
class="ui-enhanced-table__virtual-cell"
[style.width.px]="getColumnWidth(column.key)"
[style.text-align]="column.align || 'left'"
>
@if (getCellTemplate(column.key)) {
<ng-container
[ngTemplateOutlet]="getCellTemplate(column.key)!"
[ngTemplateOutletContext]="{
$implicit: getCellValue(row, column.key),
row: row,
column: column,
index: virtualStartIndex() + rowIndex
}"
></ng-container>
} @else {
<span class="ui-enhanced-table__cell-content">
{{ getCellValue(row, column.key) }}
</span>
}
</div>
}
</div>
}
<!-- Spacer after visible items -->
@if (virtualEndOffset() > 0) {
<div [style.height.px]="virtualEndOffset()"></div>
}
</div>
} @else {
<!-- Traditional table body -->
<div class="ui-enhanced-table__body">
@if (filteredAndSortedData().length === 0 && showEmptyState) {
<div class="ui-enhanced-table__empty-row">
<div class="ui-enhanced-table__empty-cell">
<div class="ui-enhanced-table__empty-content">
@if (emptyTemplate) {
<ng-container [ngTemplateOutlet]="emptyTemplate"></ng-container>
} @else {
<div class="ui-enhanced-table__empty-text">{{ emptyMessage }}</div>
}
</div>
</div>
</div>
} @else {
@for (row of filteredAndSortedData(); track trackByFn ? trackByFn($index, row) : $index; let rowIndex = $index) {
<div
class="ui-enhanced-table__row"
[class.ui-enhanced-table__row--selected]="selectedRows.has(row)"
[class.ui-enhanced-table__row--hover]="hoverable"
[class.ui-enhanced-table__row--striped]="rowIndex % 2 === 1 && variant === 'striped'"
(click)="handleRowClick(row, rowIndex)"
>
@for (column of visibleColumns(); track column.key) {
<div
class="ui-enhanced-table__cell"
[style.width.px]="getColumnWidth(column.key)"
[style.text-align]="column.align || 'left'"
>
@if (getCellTemplate(column.key)) {
<ng-container
[ngTemplateOutlet]="getCellTemplate(column.key)!"
[ngTemplateOutletContext]="{
$implicit: getCellValue(row, column.key),
row: row,
column: column,
index: rowIndex
}"
></ng-container>
} @else {
<span class="ui-enhanced-table__cell-content">
{{ getCellValue(row, column.key) }}
</span>
}
</div>
}
</div>
}
}
</div>
}
</div>
<!-- Loading Overlay -->
@if (loading) {
<div class="ui-enhanced-table__loading-overlay">
<div class="ui-enhanced-table__loading-spinner"></div>
<div class="ui-enhanced-table__loading-text">{{ loadingMessage }}</div>
</div>
}
<!-- Column Reorder Ghost -->
@if (columnReorderState.dragging) {
<div
class="ui-enhanced-table__column-ghost"
[style.left.px]="columnReorderState.ghostX"
[style.top.px]="columnReorderState.ghostY"
>
{{ columnReorderState.draggedColumn?.label }}
</div>
}
</div>
`,
styleUrl: './enhanced-table.component.scss'
})
export class EnhancedTableComponent implements OnInit, OnDestroy, AfterViewInit {
@Input() data: any[] = [];
@Input() columns: EnhancedTableColumn[] = [];
@Input() variant: EnhancedTableVariant = 'default';
@Input() size: EnhancedTableSize = 'default';
@Input() showFilterRow: boolean = false;
@Input() showEmptyState: boolean = true;
@Input() emptyMessage: string = 'No data available';
@Input() loading: boolean = false;
@Input() loadingMessage: string = 'Loading...';
@Input() hoverable: boolean = true;
@Input() selectable: boolean = false;
@Input() stickyHeader: boolean = false;
@Input() maxHeight: string = '';
@Input() trackByFn?: (index: number, item: any) => any;
@Input() cellTemplates: Record<string, TemplateRef<any>> = {};
@Input() class: string = '';
// Enhanced features
@Input() virtualScrolling: VirtualScrollConfig = { enabled: false, itemHeight: 48, buffer: 5 };
@Input() multiSort: boolean = false;
@Input() initialSorting: TableSort[] = [];
@Input() initialFilters: TableFilter[] = [];
@Input() initialColumnWidths: Record<string, number> = {};
@Input() initialColumnOrder: string[] = [];
@Output() rowClick = new EventEmitter<{row: any, index: number}>();
@Output() sortChange = new EventEmitter<EnhancedTableSortEvent>();
@Output() filterChange = new EventEmitter<EnhancedTableFilterEvent>();
@Output() columnResize = new EventEmitter<EnhancedTableColumnResizeEvent>();
@Output() columnReorder = new EventEmitter<EnhancedTableColumnReorderEvent>();
@Output() selectionChange = new EventEmitter<any[]>();
@ContentChild('emptyTemplate') emptyTemplate?: TemplateRef<any>;
@ViewChild('tableContainer') tableContainer?: ElementRef<HTMLDivElement>;
@ViewChild('scrollContainer') scrollContainer?: ElementRef<HTMLDivElement>;
// Signals for reactive state management
sorting = signal<TableSort[]>([]);
filters = signal<TableFilter[]>([]);
columnWidths = signal<Record<string, number>>({});
columnOrder = signal<string[]>([]);
selectedRows: Set<any> = new Set();
// Virtual scrolling state
virtualStartIndex = signal(0);
virtualEndIndex = signal(0);
virtualViewportHeight = signal(400);
// Column resizing state
columnResizeState = {
resizing: false,
columnKey: '',
startX: 0,
startWidth: 0
};
// Column reordering state
columnReorderState = {
dragging: false,
draggedIndex: -1,
draggedColumn: null as EnhancedTableColumn | null,
ghostX: 0,
ghostY: 0
};
// Computed properties
visibleColumns = computed(() => {
const order = this.columnOrder();
const cols = this.columns.filter(col => !col.hidden);
if (order.length === 0) {
return cols;
}
// Sort columns by order array
return order
.map(key => cols.find(col => col.key === key))
.filter(Boolean) as EnhancedTableColumn[];
});
filteredAndSortedData = computed(() => {
let result = [...this.data];
// Apply filters
const currentFilters = this.filters();
currentFilters.forEach(filter => {
result = this.applyFilter(result, filter);
});
// Apply sorting
const currentSorting = this.sorting();
if (currentSorting.length > 0) {
result.sort((a, b) => {
for (const sort of currentSorting.sort((x, y) => x.priority - y.priority)) {
const valueA = this.getCellValue(a, sort.column);
const valueB = this.getCellValue(b, sort.column);
const comparison = this.compareValues(valueA, valueB);
if (comparison !== 0) {
return sort.direction === 'desc' ? -comparison : comparison;
}
}
return 0;
});
}
return result;
});
virtualRows = computed(() => {
if (!this.virtualScrolling.enabled) return [];
const data = this.filteredAndSortedData();
const start = this.virtualStartIndex();
const end = Math.min(this.virtualEndIndex(), data.length);
return data.slice(start, end);
});
virtualStartOffset = computed(() => {
return this.virtualStartIndex() * this.virtualScrolling.itemHeight;
});
virtualEndOffset = computed(() => {
const data = this.filteredAndSortedData();
const remainingItems = data.length - this.virtualEndIndex();
return remainingItems > 0 ? remainingItems * this.virtualScrolling.itemHeight : 0;
});
ngOnInit() {
// Initialize state from inputs
this.sorting.set([...this.initialSorting]);
this.filters.set([...this.initialFilters]);
this.columnWidths.set({...this.initialColumnWidths});
this.columnOrder.set([...this.initialColumnOrder]);
// Set default column widths
this.initializeColumnWidths();
}
ngAfterViewInit() {
if (this.virtualScrolling.enabled) {
this.calculateVirtualScrolling();
}
}
ngOnDestroy() {
// Cleanup event listeners
document.removeEventListener('mousemove', this.onColumnResize);
document.removeEventListener('mouseup', this.onColumnResizeEnd);
document.removeEventListener('mousemove', this.onColumnReorderMove);
document.removeEventListener('mouseup', this.onColumnReorderEnd);
}
get tableContainerClasses(): string {
return [
'ui-enhanced-table-container',
`ui-enhanced-table-container--${this.variant}`,
`ui-enhanced-table-container--${this.size}`,
this.stickyHeader ? 'ui-enhanced-table-container--sticky-header' : '',
this.maxHeight ? 'ui-enhanced-table-container--max-height' : '',
this.loading ? 'ui-enhanced-table-container--loading' : '',
this.virtualScrolling.enabled ? 'ui-enhanced-table-container--virtual' : '',
this.class
].filter(Boolean).join(' ');
}
private initializeColumnWidths(): void {
const widths = {...this.columnWidths()};
this.columns.forEach(column => {
if (!widths[column.key]) {
widths[column.key] = column.width || 150;
}
});
this.columnWidths.set(widths);
}
getColumnWidth(columnKey: string): number {
return this.columnWidths()[columnKey] || 150;
}
// Virtual scrolling methods
calculateVirtualScrolling(): void {
if (!this.scrollContainer) return;
const container = this.scrollContainer.nativeElement;
const containerHeight = container.clientHeight;
const itemHeight = this.virtualScrolling.itemHeight;
const buffer = this.virtualScrolling.buffer || 5;
const visibleCount = Math.ceil(containerHeight / itemHeight);
const startIndex = Math.floor(container.scrollTop / itemHeight);
const bufferedStart = Math.max(0, startIndex - buffer);
const bufferedEnd = Math.min(
this.filteredAndSortedData().length,
startIndex + visibleCount + buffer
);
this.virtualStartIndex.set(bufferedStart);
this.virtualEndIndex.set(bufferedEnd);
this.virtualViewportHeight.set(this.filteredAndSortedData().length * itemHeight);
}
onVirtualScroll(event: Event): void {
this.calculateVirtualScrolling();
}
// Sorting methods
handleSort(column: EnhancedTableColumn, event?: MouseEvent): void {
if (!column.sortable) return;
const currentSorting = [...this.sorting()];
const existingIndex = currentSorting.findIndex(s => s.column === column.key);
if (this.multiSort && event?.ctrlKey) {
// Multi-sort mode
if (existingIndex >= 0) {
const existing = currentSorting[existingIndex];
if (existing.direction === 'asc') {
existing.direction = 'desc';
} else {
currentSorting.splice(existingIndex, 1);
}
} else {
currentSorting.push({
column: column.key,
direction: 'asc',
priority: currentSorting.length
});
}
} else {
// Single sort mode
if (existingIndex >= 0) {
const existing = currentSorting[existingIndex];
if (existing.direction === 'asc') {
existing.direction = 'desc';
currentSorting.splice(0, currentSorting.length, existing);
} else {
currentSorting.splice(0, currentSorting.length);
}
} else {
currentSorting.splice(0, currentSorting.length, {
column: column.key,
direction: 'asc',
priority: 0
});
}
}
// Update priorities
currentSorting.forEach((sort, index) => {
sort.priority = index;
});
this.sorting.set(currentSorting);
this.sortChange.emit({ sorting: currentSorting });
}
getSortInfo(columnKey: string): {active: boolean, direction?: 'asc' | 'desc', priority?: number} {
const sort = this.sorting().find(s => s.column === columnKey);
return sort ? {
active: true,
direction: sort.direction,
priority: sort.priority
} : { active: false };
}
getCurrentSorting(columnKey: string): TableSort[] {
return this.sorting().filter(s => s.column === columnKey);
}
getSortAttribute(columnKey: string): string | null {
const sort = this.sorting().find(s => s.column === columnKey);
return sort ? (sort.direction === 'asc' ? 'ascending' : 'descending') : null;
}
private compareValues(a: any, b: any): number {
if (a == null && b == null) return 0;
if (a == null) return -1;
if (b == null) return 1;
// Handle different data types
if (typeof a === 'string' && typeof b === 'string') {
return a.localeCompare(b);
}
if (typeof a === 'number' && typeof b === 'number') {
return a - b;
}
if (a instanceof Date && b instanceof Date) {
return a.getTime() - b.getTime();
}
// Fallback to string comparison
return String(a).localeCompare(String(b));
}
// Filtering methods
updateFilter(columnKey: string, operator: FilterOperator, value: any): void {
const currentFilters = [...this.filters()];
const existingIndex = currentFilters.findIndex(f => f.column === columnKey);
if (value === '' || value == null) {
// Remove filter if value is empty
if (existingIndex >= 0) {
currentFilters.splice(existingIndex, 1);
}
} else {
const filter: TableFilter = { column: columnKey, operator, value };
if (existingIndex >= 0) {
currentFilters[existingIndex] = filter;
} else {
currentFilters.push(filter);
}
}
this.filters.set(currentFilters);
this.filterChange.emit({ filters: currentFilters });
}
getFilterValue(columnKey: string): any {
const filter = this.filters().find(f => f.column === columnKey);
return filter?.value || '';
}
private applyFilter(data: any[], filter: TableFilter): any[] {
return data.filter(row => {
const cellValue = this.getCellValue(row, filter.column);
const filterValue = filter.value;
switch (filter.operator) {
case 'equals':
return cellValue == filterValue;
case 'contains':
return String(cellValue).toLowerCase().includes(String(filterValue).toLowerCase());
case 'startsWith':
return String(cellValue).toLowerCase().startsWith(String(filterValue).toLowerCase());
case 'endsWith':
return String(cellValue).toLowerCase().endsWith(String(filterValue).toLowerCase());
case 'gt':
return Number(cellValue) > Number(filterValue);
case 'gte':
return Number(cellValue) >= Number(filterValue);
case 'lt':
return Number(cellValue) < Number(filterValue);
case 'lte':
return Number(cellValue) <= Number(filterValue);
case 'range':
const numValue = Number(cellValue);
return numValue >= Number(filterValue) && numValue <= Number(filter.value2);
case 'in':
return Array.isArray(filterValue) && filterValue.includes(cellValue);
case 'notIn':
return Array.isArray(filterValue) && !filterValue.includes(cellValue);
default:
return true;
}
});
}
// Column resizing methods
startColumnResize = (event: MouseEvent, columnKey: string): void => {
event.preventDefault();
event.stopPropagation();
this.columnResizeState = {
resizing: true,
columnKey,
startX: event.clientX,
startWidth: this.getColumnWidth(columnKey)
};
document.addEventListener('mousemove', this.onColumnResize);
document.addEventListener('mouseup', this.onColumnResizeEnd);
};
private onColumnResize = (event: MouseEvent): void => {
if (!this.columnResizeState.resizing) return;
const deltaX = event.clientX - this.columnResizeState.startX;
const newWidth = Math.max(50, this.columnResizeState.startWidth + deltaX);
const widths = {...this.columnWidths()};
widths[this.columnResizeState.columnKey] = newWidth;
this.columnWidths.set(widths);
};
private onColumnResizeEnd = (): void => {
if (this.columnResizeState.resizing) {
this.columnResize.emit({
column: this.columnResizeState.columnKey,
width: this.getColumnWidth(this.columnResizeState.columnKey),
columnWidths: this.columnWidths()
});
}
this.columnResizeState.resizing = false;
document.removeEventListener('mousemove', this.onColumnResize);
document.removeEventListener('mouseup', this.onColumnResizeEnd);
};
autoSizeColumn(columnKey: string): void {
// Simple auto-sizing logic - could be enhanced to measure content
const column = this.columns.find(c => c.key === columnKey);
if (column) {
const defaultWidth = Math.max(column.label.length * 8 + 40, 100);
const widths = {...this.columnWidths()};
widths[columnKey] = defaultWidth;
this.columnWidths.set(widths);
}
}
// Column reordering methods
startColumnReorder = (event: MouseEvent, columnIndex: number): void => {
if (!this.visibleColumns()[columnIndex]?.draggable) return;
event.preventDefault();
this.columnReorderState = {
dragging: true,
draggedIndex: columnIndex,
draggedColumn: this.visibleColumns()[columnIndex],
ghostX: event.clientX,
ghostY: event.clientY
};
document.addEventListener('mousemove', this.onColumnReorderMove);
document.addEventListener('mouseup', this.onColumnReorderEnd);
};
private onColumnReorderMove = (event: MouseEvent): void => {
if (!this.columnReorderState.dragging) return;
this.columnReorderState.ghostX = event.clientX;
this.columnReorderState.ghostY = event.clientY;
};
private onColumnReorderEnd = (event: MouseEvent): void => {
if (!this.columnReorderState.dragging) return;
// Find drop target
const element = document.elementFromPoint(event.clientX, event.clientY);
const headerCell = element?.closest('.ui-enhanced-table__header-cell');
if (headerCell) {
const targetColumn = headerCell.getAttribute('data-column');
const targetIndex = this.visibleColumns().findIndex(c => c.key === targetColumn);
if (targetIndex >= 0 && targetIndex !== this.columnReorderState.draggedIndex) {
const newOrder = [...this.columnOrder()];
const draggedKey = this.columnReorderState.draggedColumn!.key;
// Remove dragged item and insert at new position
const currentIndex = newOrder.indexOf(draggedKey);
if (currentIndex >= 0) {
newOrder.splice(currentIndex, 1);
}
newOrder.splice(targetIndex, 0, draggedKey);
this.columnOrder.set(newOrder);
this.columnReorder.emit({
fromIndex: this.columnReorderState.draggedIndex,
toIndex: targetIndex,
columnOrder: newOrder
});
}
}
this.columnReorderState.dragging = false;
document.removeEventListener('mousemove', this.onColumnReorderMove);
document.removeEventListener('mouseup', this.onColumnReorderEnd);
};
// Utility methods
handleRowClick(row: any, index: number): void {
if (this.selectable) {
this.toggleRowSelection(row);
}
this.rowClick.emit({ row, index });
}
toggleRowSelection(row: any): void {
if (this.selectedRows.has(row)) {
this.selectedRows.delete(row);
} else {
this.selectedRows.add(row);
}
this.selectionChange.emit(Array.from(this.selectedRows));
}
getCellValue(row: any, key: string): any {
return key.split('.').reduce((obj, prop) => obj?.[prop], row) ?? '';
}
getCellTemplate(columnKey: string): TemplateRef<any> | null {
return this.cellTemplates[columnKey] || null;
}
@HostListener('window:resize')
onWindowResize(): void {
if (this.virtualScrolling.enabled) {
setTimeout(() => this.calculateVirtualScrolling(), 0);
}
}
}

View File

@@ -1,2 +1,3 @@
export * from './table.component'; export * from './table.component';
export * from '../table-actions.component'; export * from './enhanced-table.component';
export * from './table-actions.component';

View File

@@ -1,4 +1,4 @@
@use "../../../../../shared-ui/src/styles/semantic/index" as *; @use "../../../../../../shared-ui/src/styles/semantic" as *;
.ui-table-actions { .ui-table-actions {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -0,0 +1 @@
export * from './transfer-list.component';

View File

@@ -0,0 +1,481 @@
@use "../../../../../../shared-ui/src/styles/semantic/index" as *;
.ui-transfer-list {
display: flex;
align-items: flex-start;
gap: $semantic-spacing-component-lg;
width: 100%;
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);
&--sm {
gap: $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);
}
&--lg {
gap: $semantic-spacing-component-xl;
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);
}
&--disabled {
opacity: $semantic-opacity-disabled;
pointer-events: none;
}
// Transfer List Panel
&__panel {
flex: 1;
display: flex;
flex-direction: column;
background: $semantic-color-surface-primary;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
border-radius: $semantic-border-card-radius;
box-shadow: $semantic-shadow-elevation-1;
min-width: 0;
&--source {
order: 1;
}
&--target {
order: 3;
}
}
// Panel Header
&__header {
padding: $semantic-spacing-component-md;
border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary;
background: $semantic-color-surface-secondary;
border-radius: $semantic-border-card-radius $semantic-border-card-radius 0 0;
}
&__title {
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;
}
// Search
&__search {
position: relative;
margin-bottom: $semantic-spacing-component-sm;
}
&__search-icon {
position: absolute;
left: $semantic-spacing-interactive-input-padding-x;
top: 50%;
transform: translateY(-50%);
color: $semantic-color-text-tertiary;
z-index: 1;
}
&__search-input {
width: 100%;
height: $semantic-sizing-input-height-md;
padding: $semantic-spacing-interactive-input-padding-y
calc($semantic-spacing-interactive-input-padding-x * 3)
$semantic-spacing-interactive-input-padding-y
calc($semantic-spacing-interactive-input-padding-x * 2.5);
box-sizing: border-box;
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-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: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
&::placeholder {
color: $semantic-color-text-tertiary;
}
&:focus {
outline: none;
border-color: $semantic-color-border-focus;
box-shadow: 0 0 0 2px $semantic-color-focus;
}
&:disabled {
background: $semantic-color-surface-secondary;
color: $semantic-color-text-disabled;
cursor: not-allowed;
}
}
&__search-clear {
position: absolute;
right: $semantic-spacing-interactive-input-padding-x;
top: 50%;
transform: translateY(-50%);
width: $semantic-sizing-touch-minimum;
height: $semantic-sizing-touch-minimum;
padding: 0;
background: transparent;
border: none;
border-radius: $semantic-border-radius-sm;
color: $semantic-color-text-secondary;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
&:hover:not(:disabled) {
background: $semantic-color-surface-secondary;
color: $semantic-color-text-primary;
}
&:focus {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
&:disabled {
color: $semantic-color-text-disabled;
cursor: not-allowed;
}
}
// Select All
&__select-all {
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-secondary;
input[type="checkbox"] {
margin: 0;
}
&:hover {
color: $semantic-color-text-primary;
}
}
// List Container
&__list-container {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
&__list {
flex: 1;
overflow-y: auto;
padding: $semantic-spacing-component-xs;
min-height: 200px;
max-height: 400px;
&:focus {
outline: 2px solid $semantic-color-focus;
outline-offset: -2px;
}
}
// List Items
&__item {
display: flex;
align-items: center;
gap: $semantic-spacing-component-xs;
padding: $semantic-spacing-component-sm;
margin-bottom: $semantic-spacing-component-xs;
background: $semantic-color-surface-primary;
border: $semantic-border-width-1 solid transparent;
border-radius: $semantic-border-radius-sm;
cursor: pointer;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
&:last-child {
margin-bottom: 0;
}
&:hover:not(&--disabled) {
background: $semantic-color-surface-secondary;
border-color: $semantic-color-border-secondary;
}
&:focus {
outline: 2px solid $semantic-color-focus;
outline-offset: -2px;
}
&--selected {
background: $semantic-color-surface-elevated;
border-color: $semantic-color-primary;
color: $semantic-color-text-primary;
}
&--disabled {
opacity: $semantic-opacity-disabled;
cursor: not-allowed;
}
}
&__checkbox {
margin: 0;
flex-shrink: 0;
}
&__item-label {
flex: 1;
min-width: 0;
word-wrap: break-word;
}
&__empty {
padding: $semantic-spacing-component-lg;
text-align: center;
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);
}
// Controls
&__controls {
order: 2;
display: flex;
flex-direction: column;
gap: $semantic-spacing-component-sm;
align-items: center;
justify-content: center;
padding: $semantic-spacing-component-md 0;
flex-shrink: 0;
}
&__control-btn {
width: $semantic-sizing-button-height-md;
height: $semantic-sizing-button-height-md;
padding: 0;
background: $semantic-color-primary;
border: $semantic-border-width-1 solid $semantic-color-primary;
border-radius: $semantic-border-button-radius;
color: $semantic-color-on-primary;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
box-shadow: $semantic-shadow-button-rest;
&:hover:not(:disabled) {
box-shadow: $semantic-shadow-button-hover;
transform: translateY(-1px);
}
&:focus {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
&:active:not(:disabled) {
transform: translateY(0);
box-shadow: $semantic-shadow-button-rest;
}
&:disabled {
background: $semantic-color-surface-secondary;
border-color: $semantic-color-border-secondary;
color: $semantic-color-text-disabled;
cursor: not-allowed;
box-shadow: none;
}
}
// Footer
&__footer {
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
border-top: $semantic-border-width-1 solid $semantic-color-border-secondary;
background: $semantic-color-surface-secondary;
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-align: center;
}
// Size Variants
&--sm {
.ui-transfer-list__panel {
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-transfer-list__header {
padding: $semantic-spacing-component-sm;
}
.ui-transfer-list__title {
font-family: map-get($semantic-typography-heading-h5, font-family);
font-size: map-get($semantic-typography-heading-h5, font-size);
font-weight: map-get($semantic-typography-heading-h5, font-weight);
line-height: map-get($semantic-typography-heading-h5, line-height);
}
.ui-transfer-list__search-input {
height: $semantic-sizing-input-height-sm;
}
.ui-transfer-list__item {
padding: $semantic-spacing-component-xs;
}
.ui-transfer-list__control-btn {
width: $semantic-sizing-button-height-sm;
height: $semantic-sizing-button-height-sm;
}
.ui-transfer-list__footer {
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
}
}
&--lg {
.ui-transfer-list__panel {
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);
}
.ui-transfer-list__header {
padding: $semantic-spacing-component-lg;
}
.ui-transfer-list__title {
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);
}
.ui-transfer-list__search-input {
height: $semantic-sizing-input-height-lg;
}
.ui-transfer-list__item {
padding: $semantic-spacing-component-md;
}
.ui-transfer-list__control-btn {
width: $semantic-sizing-button-height-lg;
height: $semantic-sizing-button-height-lg;
}
.ui-transfer-list__footer {
padding: $semantic-spacing-component-md $semantic-spacing-component-lg;
}
}
// Responsive Design
@media (max-width: 768px) {
flex-direction: column;
gap: $semantic-spacing-component-md;
.ui-transfer-list__controls {
order: 2;
flex-direction: row;
padding: $semantic-spacing-component-sm;
}
.ui-transfer-list__panel {
&--source {
order: 1;
}
&--target {
order: 3;
}
}
.ui-transfer-list__list {
max-height: 300px;
}
}
@media (max-width: 480px) {
gap: $semantic-spacing-component-sm;
.ui-transfer-list__header {
padding: $semantic-spacing-component-sm;
}
.ui-transfer-list__title {
font-family: map-get($semantic-typography-heading-h5, font-family);
font-size: map-get($semantic-typography-heading-h5, font-size);
font-weight: map-get($semantic-typography-heading-h5, font-weight);
line-height: map-get($semantic-typography-heading-h5, line-height);
}
.ui-transfer-list__search-input {
height: $semantic-sizing-input-height-sm;
padding: $semantic-spacing-interactive-input-padding-y
calc($semantic-spacing-interactive-input-padding-x * 2.5)
$semantic-spacing-interactive-input-padding-y
calc($semantic-spacing-interactive-input-padding-x * 2);
box-sizing: border-box;
}
.ui-transfer-list__item {
padding: $semantic-spacing-component-xs;
}
.ui-transfer-list__control-btn {
width: $semantic-sizing-button-height-sm;
height: $semantic-sizing-button-height-sm;
}
.ui-transfer-list__list {
max-height: 250px;
min-height: 150px;
}
}
}

View File

@@ -0,0 +1,629 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import {
faChevronRight,
faChevronLeft,
faAnglesRight,
faAnglesLeft,
faSearch,
faTimes
} from '@fortawesome/free-solid-svg-icons';
export interface TransferListItem {
id: string | number;
label: string;
disabled?: boolean;
data?: any;
}
export type TransferListSize = 'sm' | 'md' | 'lg';
@Component({
selector: 'ui-transfer-list',
standalone: true,
imports: [CommonModule, FormsModule, FontAwesomeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-transfer-list"
[class.ui-transfer-list--{{size}}]="size"
[class.ui-transfer-list--disabled]="disabled"
role="application"
[attr.aria-label]="ariaLabel || 'Transfer list'"
>
<!-- Source List -->
<div class="ui-transfer-list__panel ui-transfer-list__panel--source">
<div class="ui-transfer-list__header">
<h3 class="ui-transfer-list__title">{{ sourceTitle }}</h3>
@if (showSearch) {
<div class="ui-transfer-list__search">
<fa-icon [icon]="faSearch" class="ui-transfer-list__search-icon"></fa-icon>
<input
type="text"
class="ui-transfer-list__search-input"
[(ngModel)]="sourceSearchTerm"
[placeholder]="searchPlaceholder"
[disabled]="disabled"
(input)="updateSourceSearch()"
/>
@if (sourceSearchTerm()) {
<button
type="button"
class="ui-transfer-list__search-clear"
(click)="clearSourceSearch()"
[disabled]="disabled"
aria-label="Clear search"
>
<fa-icon [icon]="faTimes"></fa-icon>
</button>
}
</div>
}
@if (showSelectAll) {
<label class="ui-transfer-list__select-all">
<input
type="checkbox"
[checked]="isAllSourceSelected()"
[indeterminate]="isSourceIndeterminate()"
[disabled]="disabled || filteredSourceItems().length === 0"
(change)="toggleAllSource($event)"
/>
<span>Select all ({{ getSourceSelectedCount() }}/{{ filteredSourceItems().length }})</span>
</label>
}
</div>
<div class="ui-transfer-list__list-container">
<div
class="ui-transfer-list__list"
role="listbox"
[attr.aria-label]="sourceTitle + ' items'"
tabindex="0"
(keydown)="handleSourceKeydown($event)"
>
@if (filteredSourceItems().length === 0) {
<div class="ui-transfer-list__empty" role="option">
@if (sourceSearchTerm()) {
No items match your search
} @else {
No items available
}
</div>
} @else {
@for (item of filteredSourceItems(); track item.id) {
<div
class="ui-transfer-list__item"
[class.ui-transfer-list__item--selected]="isSourceItemSelected(item.id)"
[class.ui-transfer-list__item--disabled]="disabled || item.disabled"
role="option"
[attr.aria-selected]="isSourceItemSelected(item.id)"
[attr.aria-disabled]="disabled || item.disabled"
[tabindex]="disabled || item.disabled ? -1 : 0"
(click)="toggleSourceItem(item)"
(keydown)="handleItemKeydown($event, item, 'source')"
>
@if (showCheckboxes) {
<input
type="checkbox"
class="ui-transfer-list__checkbox"
[checked]="isSourceItemSelected(item.id)"
[disabled]="disabled || item.disabled"
(change)="toggleSourceItem(item)"
tabindex="-1"
/>
}
<span class="ui-transfer-list__item-label">{{ item.label }}</span>
</div>
}
}
</div>
</div>
<div class="ui-transfer-list__footer">
{{ getSourceSelectedCount() }} of {{ filteredSourceItems().length }} selected
</div>
</div>
<!-- Transfer Controls -->
<div class="ui-transfer-list__controls">
<button
type="button"
class="ui-transfer-list__control-btn"
[disabled]="disabled || selectedSourceItems().length === 0"
(click)="moveSelectedToTarget()"
[attr.aria-label]="'Move ' + selectedSourceItems().length + ' selected items to ' + targetTitle"
title="Move selected items"
>
<fa-icon [icon]="faChevronRight"></fa-icon>
</button>
@if (showMoveAll) {
<button
type="button"
class="ui-transfer-list__control-btn"
[disabled]="disabled || filteredSourceItems().length === 0"
(click)="moveAllToTarget()"
[attr.aria-label]="'Move all ' + filteredSourceItems().length + ' items to ' + targetTitle"
title="Move all items"
>
<fa-icon [icon]="faAnglesRight"></fa-icon>
</button>
}
@if (showMoveAll) {
<button
type="button"
class="ui-transfer-list__control-btn"
[disabled]="disabled || filteredTargetItems().length === 0"
(click)="moveAllToSource()"
[attr.aria-label]="'Move all ' + filteredTargetItems().length + ' items back to ' + sourceTitle"
title="Move all items back"
>
<fa-icon [icon]="faAnglesLeft"></fa-icon>
</button>
}
<button
type="button"
class="ui-transfer-list__control-btn"
[disabled]="disabled || selectedTargetItems().length === 0"
(click)="moveSelectedToSource()"
[attr.aria-label]="'Move ' + selectedTargetItems().length + ' selected items back to ' + sourceTitle"
title="Move selected items back"
>
<fa-icon [icon]="faChevronLeft"></fa-icon>
</button>
</div>
<!-- Target List -->
<div class="ui-transfer-list__panel ui-transfer-list__panel--target">
<div class="ui-transfer-list__header">
<h3 class="ui-transfer-list__title">{{ targetTitle }}</h3>
@if (showSearch) {
<div class="ui-transfer-list__search">
<fa-icon [icon]="faSearch" class="ui-transfer-list__search-icon"></fa-icon>
<input
type="text"
class="ui-transfer-list__search-input"
[(ngModel)]="targetSearchTerm"
[placeholder]="searchPlaceholder"
[disabled]="disabled"
(input)="updateTargetSearch()"
/>
@if (targetSearchTerm()) {
<button
type="button"
class="ui-transfer-list__search-clear"
(click)="clearTargetSearch()"
[disabled]="disabled"
aria-label="Clear search"
>
<fa-icon [icon]="faTimes"></fa-icon>
</button>
}
</div>
}
@if (showSelectAll) {
<label class="ui-transfer-list__select-all">
<input
type="checkbox"
[checked]="isAllTargetSelected()"
[indeterminate]="isTargetIndeterminate()"
[disabled]="disabled || filteredTargetItems().length === 0"
(change)="toggleAllTarget($event)"
/>
<span>Select all ({{ getTargetSelectedCount() }}/{{ filteredTargetItems().length }})</span>
</label>
}
</div>
<div class="ui-transfer-list__list-container">
<div
class="ui-transfer-list__list"
role="listbox"
[attr.aria-label]="targetTitle + ' items'"
tabindex="0"
(keydown)="handleTargetKeydown($event)"
>
@if (filteredTargetItems().length === 0) {
<div class="ui-transfer-list__empty" role="option">
@if (targetSearchTerm()) {
No items match your search
} @else {
No items selected
}
</div>
} @else {
@for (item of filteredTargetItems(); track item.id) {
<div
class="ui-transfer-list__item"
[class.ui-transfer-list__item--selected]="isTargetItemSelected(item.id)"
[class.ui-transfer-list__item--disabled]="disabled || item.disabled"
role="option"
[attr.aria-selected]="isTargetItemSelected(item.id)"
[attr.aria-disabled]="disabled || item.disabled"
[tabindex]="disabled || item.disabled ? -1 : 0"
(click)="toggleTargetItem(item)"
(keydown)="handleItemKeydown($event, item, 'target')"
>
@if (showCheckboxes) {
<input
type="checkbox"
class="ui-transfer-list__checkbox"
[checked]="isTargetItemSelected(item.id)"
[disabled]="disabled || item.disabled"
(change)="toggleTargetItem(item)"
tabindex="-1"
/>
}
<span class="ui-transfer-list__item-label">{{ item.label }}</span>
</div>
}
}
</div>
</div>
<div class="ui-transfer-list__footer">
{{ getTargetSelectedCount() }} of {{ filteredTargetItems().length }} selected
</div>
</div>
</div>
`,
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<TransferListItem[]>();
@Output() targetChange = new EventEmitter<TransferListItem[]>();
@Output() itemMove = new EventEmitter<{
item: TransferListItem;
from: 'source' | 'target';
to: 'source' | 'target';
}>();
// Internal signals for reactive state management
private sourceItemsSignal = signal<TransferListItem[]>([]);
private targetItemsSignal = signal<TransferListItem[]>([]);
private selectedSourceIds = signal<Set<string | number>>(new Set());
private selectedTargetIds = signal<Set<string | number>>(new Set());
// Search terms
sourceSearchTerm = signal<string>('');
targetSearchTerm = signal<string>('');
// 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();
}
}
}

View File

@@ -9,7 +9,7 @@
// Layout & Spacing // Layout & Spacing
padding: $semantic-spacing-component-md; padding: $semantic-spacing-component-md;
margin-bottom: $semantic-spacing-component-sm; margin-bottom: $semantic-spacing-component-sm;
gap: $semantic-spacing-component-sm; gap: 16px;
// Visual Design // Visual Design
background: $semantic-color-surface-elevated; background: $semantic-color-surface-elevated;
@@ -30,7 +30,7 @@
// Size Variants // Size Variants
&--sm { &--sm {
padding: $semantic-spacing-component-sm; padding: $semantic-spacing-component-sm;
gap: $semantic-spacing-component-xs; gap: 12px;
font-family: map-get($semantic-typography-body-small, font-family); font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size); font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight); font-weight: map-get($semantic-typography-body-small, font-weight);
@@ -43,7 +43,7 @@
&--lg { &--lg {
padding: $semantic-spacing-component-lg; padding: $semantic-spacing-component-lg;
gap: $semantic-spacing-component-md; gap: 20px;
font-family: map-get($semantic-typography-body-large, font-family); font-family: map-get($semantic-typography-body-large, font-family);
font-size: map-get($semantic-typography-body-large, font-size); font-size: map-get($semantic-typography-body-large, font-size);
font-weight: map-get($semantic-typography-body-large, font-weight); font-weight: map-get($semantic-typography-body-large, font-weight);
@@ -121,7 +121,7 @@
&__icon { &__icon {
flex-shrink: 0; flex-shrink: 0;
color: $semantic-color-text-secondary; 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 margin-top: 2px; // Slight optical alignment
} }
@@ -132,10 +132,10 @@
&__title { &__title {
margin: 0 0 $semantic-spacing-content-line-tight 0; margin: 0 0 $semantic-spacing-content-line-tight 0;
font-family: map-get($semantic-typography-label, font-family); font-family: map-get($semantic-typography-heading-h4, font-family);
font-size: map-get($semantic-typography-label, font-size); font-size: map-get($semantic-typography-heading-h4, font-size);
font-weight: map-get($semantic-typography-label, font-weight); font-weight: map-get($semantic-typography-heading-h4, font-weight);
line-height: map-get($semantic-typography-label, line-height); line-height: map-get($semantic-typography-heading-h4, line-height);
color: $semantic-color-text-primary; color: $semantic-color-text-primary;
&--bold { &--bold {

View File

@@ -4,5 +4,6 @@ export * from "./skeleton-loader";
export * from "./empty-state"; export * from "./empty-state";
export * from "./loading-spinner"; export * from "./loading-spinner";
export * from "./progress-circle"; export * from "./progress-circle";
export * from "./snackbar";
export * from "./toast"; export * from "./toast";
// export * from "./theme-switcher.component"; // Temporarily disabled due to CSS variable issues // export * from "./theme-switcher.component"; // Temporarily disabled due to CSS variable issues

View File

@@ -0,0 +1 @@
export * from './snackbar.component';

View File

@@ -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;
}
}
}

View File

@@ -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: `
<div
class="ui-snackbar"
[class.ui-snackbar--sm]="size === 'sm'"
[class.ui-snackbar--md]="size === 'md'"
[class.ui-snackbar--lg]="size === 'lg'"
[class.ui-snackbar--primary]="variant === 'primary'"
[class.ui-snackbar--success]="variant === 'success'"
[class.ui-snackbar--warning]="variant === 'warning'"
[class.ui-snackbar--danger]="variant === 'danger'"
[class.ui-snackbar--info]="variant === 'info'"
[class.ui-snackbar--top]="position.startsWith('top')"
[class.ui-snackbar--left]="position.includes('left')"
[class.ui-snackbar--right]="position.includes('right')"
[class.ui-snackbar--entering]="isEntering"
[class.ui-snackbar--exiting]="isExiting"
[attr.role]="role"
[attr.aria-live]="ariaLive"
[attr.aria-labelledby]="message ? 'snackbar-message-' + snackbarId : null">
@if (showIcon && snackbarIcon) {
<fa-icon
class="ui-snackbar__icon"
[icon]="snackbarIcon"
[attr.aria-hidden]="true">
</fa-icon>
}
<div class="ui-snackbar__content" [class.ui-snackbar__content--multiline]="isMultiline">
<div
class="ui-snackbar__message"
[id]="'snackbar-message-' + snackbarId">
{{ message }}
<ng-content></ng-content>
</div>
</div>
@if (actions && actions.length > 0) {
<div class="ui-snackbar__actions">
@for (action of actions; track action.label) {
<button
type="button"
class="ui-snackbar__action"
[disabled]="action.disabled"
[attr.aria-label]="action.label"
(click)="handleActionClick(action)">
{{ action.label }}
</button>
}
</div>
}
@if (dismissible) {
<button
type="button"
class="ui-snackbar__dismiss"
[attr.aria-label]="dismissLabel"
(click)="handleDismiss()"
(keydown)="handleDismissKeydown($event)">
<fa-icon [icon]="faTimes" [attr.aria-hidden]="true"></fa-icon>
</button>
}
</div>
`,
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<void>();
@Output() expired = new EventEmitter<void>();
@Output() shown = new EventEmitter<void>();
@Output() hidden = new EventEmitter<void>();
@Output() actionClicked = new EventEmitter<SnackbarAction>();
// 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();
}
}
}

View File

@@ -14,12 +14,12 @@
} }
.ui-autocomplete__input { .ui-autocomplete__input {
font-size: 14px; font-size: 16px;
padding: 8px 12px; padding: 8px 12px;
} }
.ui-autocomplete__label { .ui-autocomplete__label {
font-size: 12px; font-size: 14px;
} }
} }
@@ -29,12 +29,12 @@
} }
.ui-autocomplete__input { .ui-autocomplete__input {
font-size: 16px; font-size: 18px;
padding: 12px 16px; padding: 12px 16px;
} }
.ui-autocomplete__label { .ui-autocomplete__label {
font-size: 14px; font-size: 16px;
} }
} }
@@ -44,12 +44,12 @@
} }
.ui-autocomplete__input { .ui-autocomplete__input {
font-size: 18px; font-size: 20px;
padding: 16px 20px; padding: 16px 20px;
} }
.ui-autocomplete__label { .ui-autocomplete__label {
font-size: 16px; font-size: 18px;
} }
} }
@@ -226,18 +226,24 @@
background: transparent; background: transparent;
color: $semantic-color-text-primary; color: $semantic-color-text-primary;
font-family: inherit; font-family: inherit;
font-size: 18px;
text-align: left; text-align: left;
cursor: pointer; cursor: pointer;
transition: all 0.2s ease; transition: all 0.2s ease;
&--sm { &--sm {
padding: 8px 12px; padding: 8px 12px;
font-size: 14px; font-size: 16px;
}
&--md {
padding: 12px 16px;
font-size: 18px;
} }
&--lg { &--lg {
padding: 16px 20px; padding: 16px 20px;
font-size: 18px; font-size: 20px;
} }
&:hover { &:hover {
@@ -330,18 +336,26 @@
} }
// Responsive Design // Responsive Design
@media (max-width: 768px - 1) { @media (max-width: 767px) {
&__dropdown { &__dropdown {
max-height: 200px; max-height: 200px;
} }
} }
@media (max-width: 576px - 1) { @media (max-width: 575px) {
// Mobile adjustments // Mobile adjustments - respect size variants
&__input { &--sm &__input {
font-size: 16px; font-size: 16px;
} }
&--md &__input {
font-size: 18px;
}
&--lg &__input {
font-size: 20px;
}
&__dropdown { &__dropdown {
max-height: 180px; max-height: 180px;
} }

View File

@@ -29,8 +29,11 @@ export interface AutocompleteOption {
template: ` template: `
<div <div
class="ui-autocomplete" class="ui-autocomplete"
[class.ui-autocomplete--{{size}}]="size" [class.ui-autocomplete--sm]="size === 'sm'"
[class.ui-autocomplete--{{variant}}]="variant" [class.ui-autocomplete--md]="size === 'md'"
[class.ui-autocomplete--lg]="size === 'lg'"
[class.ui-autocomplete--outlined]="variant === 'outlined'"
[class.ui-autocomplete--filled]="variant === 'filled'"
[class.ui-autocomplete--focused]="isFocused()" [class.ui-autocomplete--focused]="isFocused()"
[class.ui-autocomplete--error]="hasError" [class.ui-autocomplete--error]="hasError"
[class.ui-autocomplete--disabled]="disabled" [class.ui-autocomplete--disabled]="disabled"
@@ -89,7 +92,8 @@ export interface AutocompleteOption {
@if (isDropdownOpen()) { @if (isDropdownOpen()) {
<div <div
class="ui-autocomplete__dropdown" class="ui-autocomplete__dropdown"
[class.ui-autocomplete__dropdown--{{size}}]="size" [class.ui-autocomplete__dropdown--sm]="size === 'sm'"
[class.ui-autocomplete__dropdown--lg]="size === 'lg'"
[id]="autocompleteId + '-listbox'" [id]="autocompleteId + '-listbox'"
role="listbox" role="listbox"
[attr.aria-label]="'Options'" [attr.aria-label]="'Options'"
@@ -107,7 +111,9 @@ export interface AutocompleteOption {
<button <button
type="button" type="button"
class="ui-autocomplete__option" class="ui-autocomplete__option"
[class.ui-autocomplete__option--{{size}}]="size" [class.ui-autocomplete__option--sm]="size === 'sm'"
[class.ui-autocomplete__option--md]="size === 'md'"
[class.ui-autocomplete__option--lg]="size === 'lg'"
[class.ui-autocomplete__option--highlighted]="highlightedIndex() === index" [class.ui-autocomplete__option--highlighted]="highlightedIndex() === index"
[class.ui-autocomplete__option--selected]="isSelected(option)" [class.ui-autocomplete__option--selected]="isSelected(option)"
[class.ui-autocomplete__option--disabled]="option.disabled" [class.ui-autocomplete__option--disabled]="option.disabled"

View File

@@ -178,7 +178,7 @@
font-size: 0.75rem; font-size: 0.75rem;
color: $semantic-color-text-tertiary; color: $semantic-color-text-tertiary;
text-align: right; text-align: right;
margin-top: $semantic-spacing-stack-xs; margin-top: $semantic-spacing-stack-sm;
&--over-limit { &--over-limit {
color: $semantic-color-error; color: $semantic-color-error;

View File

@@ -11,3 +11,4 @@ export * from './file-upload';
export * from './form-field'; export * from './form-field';
export * from './range-slider'; export * from './range-slider';
export * from './color-picker'; export * from './color-picker';
export * from './tag-input';

View File

@@ -34,8 +34,8 @@
.radio-button__inner-circle { .radio-button__inner-circle {
width: 8px; width: 8px;
height: 8px; height: 8px;
top: 4px; top: 50%;
left: 4px; left: 50%;
} }
.radio-button__label { .radio-button__label {
@@ -68,8 +68,8 @@
.radio-button__inner-circle { .radio-button__inner-circle {
width: 10px; width: 10px;
height: 10px; height: 10px;
top: 5px; top: 50%;
left: 5px; left: 50%;
} }
.radio-button__label { .radio-button__label {
@@ -102,8 +102,8 @@
.radio-button__inner-circle { .radio-button__inner-circle {
width: 12px; width: 12px;
height: 12px; height: 12px;
top: 6px; top: 50%;
left: 6px; left: 50%;
} }
.radio-button__label { .radio-button__label {
@@ -298,12 +298,12 @@
position: absolute; position: absolute;
border-radius: $semantic-border-radius-full; border-radius: $semantic-border-radius-full;
opacity: 0; opacity: 0;
transform: scale(0); transform: translate(-50%, -50%) scale(0);
transition: all 0.2s ease-in-out; transition: all 0.2s ease-in-out;
&--selected { &--selected {
opacity: 1; opacity: 1;
transform: scale(1); transform: translate(-50%, -50%) scale(1);
} }
} }
@@ -390,8 +390,8 @@
.radio-button__inner-circle { .radio-button__inner-circle {
width: 10px; width: 10px;
height: 10px; height: 10px;
top: 5px; top: 50%;
left: 5px; left: 50%;
} }
.radio-button__label { .radio-button__label {

View File

@@ -1,4 +1,4 @@
@use "../../../../../shared-ui/src/styles/semantic/index" as *; @use "../../../../../../shared-ui/src/styles/semantic/index" as *;
// Tokens available globally via main application styles // Tokens available globally via main application styles

View File

@@ -0,0 +1 @@
export * from './tag-input.component';

View File

@@ -0,0 +1,197 @@
@use "../../../../../../shared-ui/src/styles/semantic/index" as *;
.ui-tag-input {
display: flex;
flex-wrap: wrap;
align-items: center;
position: relative;
// Layout & Spacing
padding: $semantic-spacing-interactive-input-padding-y $semantic-spacing-interactive-input-padding-x;
gap: $semantic-spacing-component-xs;
min-height: $semantic-sizing-input-height-md;
// Visual Design
background: $semantic-color-surface-primary;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
border-radius: $semantic-border-input-radius;
// Typography
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);
// Transitions
transition: border-color $semantic-motion-duration-fast $semantic-motion-easing-ease,
box-shadow $semantic-motion-duration-fast $semantic-motion-easing-ease;
// Size Variants
&--sm {
min-height: $semantic-sizing-input-height-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);
.ui-tag-input__input {
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 {
// Already defined above
}
&--lg {
min-height: $semantic-sizing-input-height-lg;
padding: $semantic-spacing-component-md;
gap: $semantic-spacing-component-sm;
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);
.ui-tag-input__input {
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);
}
}
// Style Variants
&--outlined {
background: transparent;
border-color: $semantic-color-border-primary;
}
&--filled {
background: $semantic-color-surface-secondary;
border-color: $semantic-color-border-subtle;
}
// State Variants
&--disabled {
opacity: $semantic-opacity-disabled;
cursor: not-allowed;
pointer-events: none;
.ui-tag-input__input {
cursor: not-allowed;
color: $semantic-color-text-disabled;
}
}
&--error {
border-color: $semantic-color-border-error;
&:focus-within {
border-color: $semantic-color-border-error;
box-shadow: 0 0 0 2px rgba($semantic-color-danger, 0.2);
}
}
// Interactive States
&:not(.ui-tag-input--disabled) {
&:hover {
border-color: $semantic-color-border-focus;
}
&:focus-within {
border-color: $semantic-color-focus;
box-shadow: 0 0 0 2px rgba($semantic-color-focus, 0.2);
}
}
// Tags Container
&__tags {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: $semantic-spacing-component-xs;
flex: 1;
min-width: 0;
}
// Input Element
&__input {
border: none;
background: transparent;
outline: none;
flex: 1;
min-width: 120px;
padding: 0;
margin: 0;
color: $semantic-color-text-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);
&::placeholder {
color: $semantic-color-text-tertiary;
}
&:disabled {
color: $semantic-color-text-disabled;
cursor: not-allowed;
}
}
// Tag Item (using existing chip component)
&__tag {
.ui-chip {
margin: 0;
}
}
// Max Tags Reached Indicator
&--max-reached {
.ui-tag-input__input {
display: none;
}
}
// Focus Management
&__focus-trap {
position: absolute;
opacity: 0;
pointer-events: none;
top: -1px;
left: -1px;
}
// Responsive Design
@media (max-width: 767px) {
.ui-tag-input__input {
min-width: 80px;
}
}
}
// Error Message
.ui-tag-input-error {
margin-top: $semantic-spacing-component-xs;
color: $semantic-color-danger;
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);
}
// Help Text
.ui-tag-input-help {
margin-top: $semantic-spacing-component-xs;
color: $semantic-color-text-secondary;
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);
}

View File

@@ -0,0 +1,322 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, forwardRef, ElementRef, ViewChild } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms';
import { ChipComponent } from '../../data-display/chip/chip.component';
import { faXmark } from '@fortawesome/free-solid-svg-icons';
export type TagInputSize = 'sm' | 'md' | 'lg';
export type TagInputVariant = 'outlined' | 'filled';
@Component({
selector: 'ui-tag-input',
standalone: true,
imports: [CommonModule, ChipComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => TagInputComponent),
multi: true
}
],
template: `
<div
[class]="containerClasses"
[attr.aria-label]="ariaLabel"
[attr.aria-describedby]="ariaDescribedBy"
(click)="focusInput()">
<div class="ui-tag-input__tags">
@for (tag of tags; track tag; let i = $index) {
<div class="ui-tag-input__tag">
<ui-chip
[label]="tag"
[size]="chipSize"
[variant]="chipVariant"
[removable]="!disabled && !readonly"
[closeIcon]="faXmark"
[disabled]="disabled"
(chipRemove)="removeTag(i)"
[attr.aria-label]="'Remove tag: ' + tag">
</ui-chip>
</div>
}
</div>
@if (!isMaxTagsReached) {
<input
#inputElement
class="ui-tag-input__input"
type="text"
[value]="inputValue"
[placeholder]="effectivePlaceholder"
[disabled]="disabled"
[readonly]="readonly"
[attr.aria-label]="inputAriaLabel"
[attr.maxlength]="maxTagLength"
(input)="handleInput($event)"
(keydown)="handleKeydown($event)"
(blur)="handleBlur()"
(focus)="handleFocus()">
}
</div>
@if (errorMessage) {
<div class="ui-tag-input-error" [attr.id]="errorId">
{{ errorMessage }}
</div>
}
@if (helpText && !errorMessage) {
<div class="ui-tag-input-help" [attr.id]="helpId">
{{ helpText }}
</div>
}
`,
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<string>();
@Output() tagRemove = new EventEmitter<{ tag: string; index: number }>();
@Output() maxTagsReached = new EventEmitter<void>();
@Output() duplicateTag = new EventEmitter<string>();
@Output() tagValidationError = new EventEmitter<{ tag: string; error: string }>();
@ViewChild('inputElement') inputElement!: ElementRef<HTMLInputElement>;
// 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);
}
}

View File

@@ -4,12 +4,11 @@
display: block; display: block;
position: relative; position: relative;
width: 100%; width: 100%;
margin-left: auto; margin: 0 auto;
margin-right: auto; box-sizing: border-box;
// Base padding // Base padding
padding-left: $semantic-spacing-component-md; padding: 0 $semantic-spacing-component-md;
padding-right: $semantic-spacing-component-md;
// Size variants // Size variants
&--xs { &--xs {

View File

@@ -18,19 +18,7 @@ type ScrollDirection = 'y' | 'x' | 'both';
encapsulation: ViewEncapsulation.None, encapsulation: ViewEncapsulation.None,
template: ` template: `
<div <div
class="ui-container" [ngClass]="getClasses()"
[class.ui-container--{{size}}]="size"
[class.ui-container--{{variant}}]="variant !== 'default'"
[class.ui-container--{{background}}]="background !== 'transparent'"
[class.ui-container--padding-{{padding}}]="padding"
[class.ui-container--padding-y-{{paddingY}}]="paddingY"
[class.ui-container--flex-{{flexDirection}}]="flexDirection && variant === 'flex'"
[class.ui-container--flex-{{flexJustify}}]="flexJustify && variant === 'flex'"
[class.ui-container--grid-{{gridColumns}}]="gridColumns && variant === 'grid'"
[class.ui-container--scrollable]="scrollable === 'y'"
[class.ui-container--scrollable-x]="scrollable === 'x'"
[class.ui-container--scrollable-both]="scrollable === 'both'"
[class.ui-container--responsive]="responsive"
[style.max-width]="customMaxWidth" [style.max-width]="customMaxWidth"
[style.min-height]="customMinHeight" [style.min-height]="customMinHeight"
[style.max-height]="customMaxHeight" [style.max-height]="customMaxHeight"
@@ -56,4 +44,50 @@ export class ContainerComponent {
@Input() customMinHeight?: string; @Input() customMinHeight?: string;
@Input() customMaxHeight?: string; @Input() customMaxHeight?: string;
@Input() role?: string; @Input() role?: string;
getClasses(): Record<string, boolean> {
const classes: Record<string, boolean> = {
'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;
}
} }

View File

@@ -4,58 +4,60 @@
display: block; display: block;
position: relative; position: relative;
// Default spacing // Default spacing (only when not flexible)
width: tokens.$semantic-spacing-md; &:not(.ui-spacer--flexible) {
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 {
width: tokens.$semantic-spacing-md; width: tokens.$semantic-spacing-md;
height: 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; width: tokens.$semantic-spacing-lg;
height: tokens.$semantic-spacing-lg; height: tokens.$semantic-spacing-lg;
} }
&--xl { &--xl:not(.ui-spacer--flexible) {
width: tokens.$semantic-spacing-xl; width: tokens.$semantic-spacing-xl;
height: tokens.$semantic-spacing-xl; height: tokens.$semantic-spacing-xl;
} }
&--2xl { &--2xl:not(.ui-spacer--flexible) {
width: tokens.$semantic-spacing-2xl; width: tokens.$semantic-spacing-2xl;
height: tokens.$semantic-spacing-2xl; height: tokens.$semantic-spacing-2xl;
} }
&--3xl { &--3xl:not(.ui-spacer--flexible) {
width: tokens.$semantic-spacing-3xl; width: tokens.$semantic-spacing-3xl;
height: tokens.$semantic-spacing-3xl; height: tokens.$semantic-spacing-3xl;
} }
&--4xl { &--4xl:not(.ui-spacer--flexible) {
width: tokens.$semantic-spacing-4xl; width: tokens.$semantic-spacing-4xl;
height: tokens.$semantic-spacing-4xl; height: tokens.$semantic-spacing-4xl;
} }
&--5xl { &--5xl:not(.ui-spacer--flexible) {
width: tokens.$semantic-spacing-5xl; width: tokens.$semantic-spacing-5xl;
height: tokens.$semantic-spacing-5xl; height: tokens.$semantic-spacing-5xl;
} }
// Directional variants // Directional variants (only when not flexible)
&--horizontal { &--horizontal:not(.ui-spacer--flexible) {
height: 0; height: 0;
width: tokens.$semantic-spacing-md; width: tokens.$semantic-spacing-md;
@@ -70,7 +72,7 @@
&.ui-spacer--5xl { width: tokens.$semantic-spacing-5xl; } &.ui-spacer--5xl { width: tokens.$semantic-spacing-5xl; }
} }
&--vertical { &--vertical:not(.ui-spacer--flexible) {
width: 0; width: 0;
height: tokens.$semantic-spacing-md; height: tokens.$semantic-spacing-md;
@@ -87,15 +89,21 @@
// Flexible spacer (grows to fill available space) // Flexible spacer (grows to fill available space)
&--flexible { &--flexible {
flex: 1; flex: 1 1 0;
&.ui-spacer--horizontal { &.ui-spacer--horizontal {
width: auto !important; height: 0;
min-width: tokens.$semantic-spacing-xs; min-width: tokens.$semantic-spacing-xs;
} }
&.ui-spacer--vertical { &.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; min-height: tokens.$semantic-spacing-xs;
} }
} }

View File

@@ -15,12 +15,7 @@ type SpacerDirection = 'both' | 'horizontal' | 'vertical';
template: ` template: `
<div <div
class="ui-spacer" class="ui-spacer"
[class.ui-spacer--{{size}}]="size && !variant" [ngClass]="getClasses()"
[class.ui-spacer--{{variant}}]="variant"
[class.ui-spacer--{{direction}}]="direction !== 'both'"
[class.ui-spacer--flexible]="flexible"
[class.ui-spacer--responsive]="responsive"
[class.ui-spacer--debug]="debug"
[style.width]="customWidth" [style.width]="customWidth"
[style.height]="customHeight" [style.height]="customHeight"
[attr.aria-hidden]="true"> [attr.aria-hidden]="true">
@@ -37,4 +32,26 @@ export class SpacerComponent {
@Input() debug = false; @Input() debug = false;
@Input() customWidth?: string; @Input() customWidth?: string;
@Input() customHeight?: string; @Input() customHeight?: string;
getClasses(): Record<string, boolean> {
const classes: Record<string, boolean> = {};
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;
}
} }

View File

@@ -15,12 +15,14 @@
.ui-video-player { .ui-video-player {
position: relative; position: relative;
width: 100%; width: 100%;
max-width: 800px;
background: $semantic-color-surface; background: $semantic-color-surface;
border-radius: 12px; border-radius: 12px;
overflow: hidden; overflow: hidden;
box-shadow: $semantic-shadow-elevation-2; box-shadow: $semantic-shadow-elevation-2;
// Default size (medium)
max-width: 800px;
&--fullscreen { &--fullscreen {
max-width: none !important; max-width: none !important;
width: 100vw !important; width: 100vw !important;
@@ -43,26 +45,17 @@
} }
} }
// Size variants // Size variants - placed after default to override
&--sm { &--sm {
max-width: 400px; max-width: 400px;
.ui-video-player__video-container {
padding-bottom: 56.25%; // 16:9 aspect ratio
}
} }
&--md { &--md {
max-width: 800px; max-width: 800px;
.ui-video-player__video-container {
padding-bottom: 56.25%; // 16:9 aspect ratio
}
} }
&--lg { &--lg {
max-width: 1200px; max-width: 1200px;
.ui-video-player__video-container {
padding-bottom: 56.25%; // 16:9 aspect ratio
}
} }
// Variant styles // Variant styles
@@ -73,13 +66,17 @@
} }
&--theater { &--theater {
max-width: none;
width: 100%; width: 100%;
background: #000; background: #000;
.ui-video-player__video-container { .ui-video-player__video-container {
padding-bottom: 42.85%; // 21:9 aspect ratio for theater mode 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 { &__video-container {
@@ -367,12 +364,17 @@
// Responsive design // Responsive design
@media (max-width: 768px) { @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 { &--lg {
max-width: none; max-width: 100%;
} }
&__controls { &__controls {

View File

@@ -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;
}
}
}

View File

@@ -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: `
<nav
class="ui-bottom-navigation"
[class.ui-bottom-navigation--{{size}}]="size"
[class.ui-bottom-navigation--{{variant}}]="variant"
[class.ui-bottom-navigation--elevated]="elevated"
[class.ui-bottom-navigation--hidden]="hidden"
[attr.aria-label]="ariaLabel"
role="navigation">
@for (item of items; track item.id) {
<a
class="ui-bottom-navigation__item"
[class.ui-bottom-navigation__item--active]="activeItemId === item.id"
[class.ui-bottom-navigation__item--disabled]="item.disabled"
[class.ui-bottom-navigation__item--has-badge]="item.badge"
[href]="item.href || null"
[attr.aria-current]="activeItemId === item.id ? 'page' : null"
[attr.aria-disabled]="item.disabled"
[tabindex]="item.disabled ? -1 : 0"
(click)="handleItemClick($event, item)"
(keydown)="handleItemKeydown($event, item)">
@if (item.icon) {
<span class="ui-bottom-navigation__icon" aria-hidden="true">
<ng-content [select]="'[data-icon=' + item.id + ']'"></ng-content>
</span>
}
@if (item.label) {
<span class="ui-bottom-navigation__label">{{ item.label }}</span>
}
@if (item.badge) {
<span class="ui-bottom-navigation__badge" [attr.aria-label]="getBadgeAriaLabel(item.badge)">
{{ getBadgeDisplay(item.badge) }}
</span>
}
</a>
}
</nav>
`,
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<BottomNavigationItem>();
@Output() activeItemChanged = new EventEmitter<string>();
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}`;
}
}

View File

@@ -0,0 +1 @@
export * from './bottom-navigation.component';

View File

@@ -1,5 +1,7 @@
export * from './appbar'; export * from './appbar';
export * from './bottom-navigation';
export * from './breadcrumb'; export * from './breadcrumb';
export * from './menu'; export * from './menu';
export * from './pagination'; export * from './pagination';
export * from './stepper';
export * from './tab-group'; export * from './tab-group';

View File

@@ -131,7 +131,7 @@
// ========================================================================== // ==========================================================================
&--indent { &--indent {
margin-left: $semantic-spacing-component-xl; margin-left: $semantic-spacing-component-md;
} }
&--with-divider { &--with-divider {

View File

@@ -12,13 +12,23 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; 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 { .submenu-content {
overflow: hidden; 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; 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 // 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 { .menu-submenu {
//border-left: 2px solid $semantic-color-border-secondary; //border-left: 2px solid $semantic-color-border-secondary;
@@ -26,6 +36,7 @@
background-color: $semantic-color-surface-elevated; background-color: $semantic-color-surface-elevated;
box-shadow: none; box-shadow: none;
position: relative; position: relative;
z-index: 2;
} }
} }
@@ -54,6 +65,10 @@
.submenu-content { .submenu-content {
.menu-item { .menu-item {
position: relative; position: relative;
// Ensure submenu items have proper stacking context
z-index: 3;
// Clear any potential interaction with parent items
pointer-events: auto;
&::before { &::before {
content: ''; content: '';
@@ -82,10 +97,17 @@
width: 100% !important; width: 100% !important;
margin-right: 0 !important; margin-right: 0 !important;
margin-bottom: 0; margin-bottom: 0;
// Create isolated interaction area to prevent hover conflicts
isolation: isolate;
&:not(:last-child) { &:not(:last-child) {
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
// Ensure hover states don't bleed to adjacent items
&:hover {
z-index: 4;
}
} }
} }

View File

@@ -0,0 +1 @@
export * from './stepper.component';

View File

@@ -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;
}
}
}

View File

@@ -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: `
<div [class]="getStepperClasses()">
@for (step of steps; track step.id; let i = $index; let isLast = $last) {
<div class="ui-stepper__step">
<div
[class]="getStepHeaderClasses(step)"
[attr.role]="clickable ? 'button' : 'presentation'"
[attr.tabindex]="getTabIndex(step)"
[attr.aria-current]="step.current ? 'step' : null"
[attr.aria-disabled]="step.disabled"
(click)="onStepClick(step, i)"
(keydown)="onStepKeydown($event, step, i)">
<div [class]="getStepIndicatorClasses(step)">
@if (step.completed && !step.error) {
<fa-icon [icon]="checkIcon" />
} @else if (step.error) {
<fa-icon [icon]="errorIcon" />
} @else {
{{ i + 1 }}
}
</div>
<div class="ui-stepper__step-content">
<h4 class="ui-stepper__step-title">{{ step.title }}</h4>
@if (step.description) {
<p class="ui-stepper__step-description">{{ step.description }}</p>
}
</div>
</div>
@if (!isLast) {
<div [class]="getConnectorClasses(step, steps[i + 1]) + ' ui-stepper__connector--outgoing'"></div>
}
@if (i > 0) {
<div [class]="getConnectorClasses(steps[i - 1], step) + ' ui-stepper__connector--incoming'"></div>
}
</div>
}
</div>
`,
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;
});
}
}

View File

@@ -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
}
}
}

View File

@@ -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: `
<button
type="button"
class="ui-command-palette-item"
[class.ui-command-palette-item--selected]="selected"
[class.ui-command-palette-item--disabled]="result.command.disabled"
[attr.aria-selected]="selected"
[attr.aria-disabled]="result.command.disabled"
role="option"
(click)="handleClick()"
(mouseenter)="handleMouseEnter()"
>
<div class="ui-command-palette-item__content">
@if (result.command.icon) {
<div class="ui-command-palette-item__icon" aria-hidden="true">
<fa-icon [icon]="result.command.icon"></fa-icon>
</div>
}
<div class="ui-command-palette-item__text">
<div class="ui-command-palette-item__title">
@if (result.highlightRanges.length > 0) {
@for (segment of getHighlightedSegments(); track $index) {
@if (segment.highlighted) {
<mark class="ui-command-palette-item__highlight">{{ segment.text }}</mark>
} @else {
<span>{{ segment.text }}</span>
}
}
} @else {
{{ result.command.title }}
}
</div>
@if (result.command.description) {
<div class="ui-command-palette-item__description">
{{ result.command.description }}
</div>
}
</div>
@if (result.command.shortcut && result.command.shortcut.length > 0) {
<div class="ui-command-palette-item__shortcut" aria-hidden="true">
@for (key of result.command.shortcut; track key; let last = $last) {
<kbd class="ui-command-palette-item__kbd">{{ key }}</kbd>
@if (!last) {
<span class="ui-command-palette-item__plus">+</span>
}
}
</div>
}
</div>
</button>
`,
styleUrl: './command-palette-item.component.scss'
})
export class CommandPaletteItemComponent {
@Input({ required: true }) result!: CommandSearchResult;
@Input() selected = false;
@Output() itemClick = new EventEmitter<CommandSearchResult>();
@Output() itemHover = new EventEmitter<CommandSearchResult>();
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;
}
}

View File

@@ -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;
}
}

View File

@@ -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: `
<div
class="ui-command-palette"
[class.ui-command-palette--{{size}}]="size"
[class.ui-command-palette--open]="isOpen()"
[class.ui-command-palette--entering]="isEntering()"
[class.ui-command-palette--leaving]="isLeaving()"
role="dialog"
[attr.aria-modal]="isOpen()"
[attr.aria-labelledby]="'command-palette-' + paletteId + '-label'"
(keydown)="handleKeydown($event)"
>
@if (isVisible()) {
<!-- Backdrop -->
<div
class="ui-command-palette__backdrop"
(click)="close()"
aria-hidden="true"
></div>
<!-- Modal Container -->
<div class="ui-command-palette__container">
<!-- Search Input -->
<div class="ui-command-palette__search">
<div class="ui-command-palette__search-icon" aria-hidden="true">
<fa-icon [icon]="faSearch"></fa-icon>
</div>
<input
#searchInput
type="search"
class="ui-command-palette__input"
[placeholder]="commandService.config().placeholder"
[value]="searchQuery()"
(input)="handleSearchInput($event)"
autocomplete="off"
autocorrect="off"
autocapitalize="off"
spellcheck="false"
[id]="'command-palette-' + paletteId + '-input'"
[attr.aria-label]="'Search commands'"
[attr.aria-describedby]="'command-palette-' + paletteId + '-results'"
role="combobox"
[attr.aria-expanded]="searchResults().length > 0"
[attr.aria-activedescendant]="getActiveDescendantId()"
aria-haspopup="listbox"
/>
@if (searchQuery()) {
<button
type="button"
class="ui-command-palette__clear"
(click)="clearSearch()"
aria-label="Clear search"
tabindex="-1"
>
×
</button>
}
</div>
<!-- Results -->
<div
class="ui-command-palette__results"
[id]="'command-palette-' + paletteId + '-results'"
role="listbox"
[attr.aria-label]="'Command results'"
>
@if (searchResults().length > 0) {
@if (commandService.config().showCategories && !searchQuery()) {
<!-- Grouped by category -->
@for (group of groupedResults(); track group.category) {
<div class="ui-command-palette__group">
<div class="ui-command-palette__group-header">
<div class="ui-command-palette__group-icon" aria-hidden="true">
<fa-icon [icon]="getCategoryIcon(group.category)"></fa-icon>
</div>
<h3 class="ui-command-palette__group-title">{{ group.title }}</h3>
<span class="ui-command-palette__group-count">{{ group.commands.length }}</span>
</div>
@for (result of getResultsForGroup(group); track result.command.id; let i = $index) {
<ui-command-palette-item
[result]="result"
[selected]="getGlobalIndex(group, i) === selectedIndex()"
[id]="'command-' + result.command.id"
(itemClick)="executeCommand(result)"
(itemHover)="setSelectedIndex(getGlobalIndex(group, i))"
/>
}
</div>
}
} @else {
<!-- Flat results list -->
@for (result of searchResults(); track result.command.id; let i = $index) {
<ui-command-palette-item
[result]="result"
[selected]="i === selectedIndex()"
[id]="'command-' + result.command.id"
(itemClick)="executeCommand(result)"
(itemHover)="setSelectedIndex(i)"
/>
}
}
} @else {
<div class="ui-command-palette__empty" role="status">
<div class="ui-command-palette__empty-icon" aria-hidden="true">
<fa-icon [icon]="faTerminal"></fa-icon>
</div>
<p class="ui-command-palette__empty-text">
{{ commandService.config().noResultsMessage }}
</p>
</div>
}
</div>
<!-- Footer -->
@if (showFooter) {
<div class="ui-command-palette__footer">
<div class="ui-command-palette__footer-section">
<kbd class="ui-command-palette__footer-kbd">↑↓</kbd>
<span>Navigate</span>
</div>
<div class="ui-command-palette__footer-section">
<kbd class="ui-command-palette__footer-kbd">Enter</kbd>
<span>Select</span>
</div>
<div class="ui-command-palette__footer-section">
<kbd class="ui-command-palette__footer-kbd">Esc</kbd>
<span>Close</span>
</div>
</div>
}
</div>
}
</div>
`,
styleUrl: './command-palette.component.scss'
})
export class CommandPaletteComponent implements OnInit, OnDestroy {
@ViewChild('searchInput') searchInput!: ElementRef<HTMLInputElement>;
@Input() size: CommandPaletteSize = 'lg';
@Input() showFooter = true;
@Input() autoFocus = true;
@Output() opened = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
@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<CommandSearchResult[]>([]);
// 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<CommandCategory, CommandSearchResult[]>();
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<void> {
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();
}
}
}

View File

@@ -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<Command[]>([]);
private _recentCommands = signal<RecentCommand[]>([]);
private _config = signal<CommandPaletteConfig>({
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<CommandCategory, Command[]>();
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<void> {
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<CommandPaletteConfig>): 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, string> = {
[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, number> = {
[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;
}
}

View File

@@ -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<void>;
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;
}

View File

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

View File

@@ -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';

View File

@@ -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;
}

View File

@@ -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<any>;
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<string, any>;
}
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()) {
<!-- Backdrop (optional) -->
@if (backdrop) {
<div
class="ui-floating-toolbar-backdrop"
(click)="handleBackdropClick($event)"
></div>
}
<!-- Floating Toolbar Container -->
<div
class="ui-floating-toolbar"
[class.ui-floating-toolbar--{{size}}]="size"
[class.ui-floating-toolbar--{{variant}}]="variant"
[class.ui-floating-toolbar--{{computedPosition()}}]="computedPosition()"
[class.ui-floating-toolbar--visible]="isVisible()"
[class.ui-floating-toolbar--entering]="isEntering()"
[class.ui-floating-toolbar--leaving]="isLeaving()"
[class.ui-floating-toolbar--disabled]="disabled"
[class.ui-floating-toolbar--{{showAnimation}}]="showAnimation !== 'none'"
[style.top.px]="positionData().top"
[style.left.px]="positionData().left"
[attr.role]="'toolbar'"
[attr.aria-label]="label || 'Floating toolbar'"
[attr.aria-orientation]="'horizontal'"
[attr.tabindex]="0"
(keydown)="handleKeydown($event)"
(click)="$event.stopPropagation()"
#toolbarElement
>
<!-- Optional Label -->
@if (label && showLabel) {
<div class="ui-floating-toolbar__label">{{ label }}</div>
<div class="ui-floating-toolbar__divider"></div>
}
<!-- Actions Container -->
<div class="ui-floating-toolbar__actions">
@for (action of visibleActions(); track action.id) {
<!-- Divider -->
@if (action.divider) {
<div class="ui-floating-toolbar__divider"></div>
}
<!-- Action Button -->
<button
type="button"
class="ui-floating-toolbar__action"
[class.ui-floating-toolbar__action--disabled]="action.disabled"
[disabled]="action.disabled"
[attr.aria-label]="action.label"
[attr.title]="action.tooltip || action.label"
[attr.data-shortcut]="action.shortcut"
(click)="handleActionClick(action, $event)"
(keydown)="handleActionKeydown(action, $event)"
>
<!-- Icon (if provided) -->
@if (action.icon) {
<i [class]="action.icon" aria-hidden="true"></i>
}
<!-- Icon Template (if provided) -->
@if (action.iconTemplate) {
<ng-container *ngTemplateOutlet="action.iconTemplate"></ng-container>
}
<!-- Label (for screen readers or when no icon) -->
<span
class="ui-floating-toolbar__action-label"
[class.ui-floating-toolbar__action-label--visible]="!action.icon && !action.iconTemplate"
>
{{ action.label }}
</span>
<!-- Shortcut indicator -->
@if (action.shortcut && showShortcuts) {
<span class="ui-floating-toolbar__shortcut">{{ action.shortcut }}</span>
}
</button>
}
</div>
<!-- Custom Content Slot -->
<ng-content></ng-content>
</div>
}
`,
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<HTMLElement>;
// 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<boolean>();
@Output() shown = new EventEmitter<void>();
@Output() hidden = new EventEmitter<void>();
@Output() actionClicked = new EventEmitter<{ action: ToolbarAction; event: Event }>();
@Output() contextChanged = new EventEmitter<ToolbarContext>();
@Output() positionChanged = new EventEmitter<ToolbarPosition>();
@Output() backdropClicked = new EventEmitter<MouseEvent>();
@Output() escapePressed = new EventEmitter<KeyboardEvent>();
// Internal state signals
private _visible = signal(false);
private _entering = signal(false);
private _leaving = signal(false);
private _computedPosition = signal<ToolbarPosition>(this.position);
private _positionData = signal<ToolbarPositionData>({ 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<ToolbarAction>): 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<ToolbarPosition, ToolbarPositionData> {
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<ToolbarPosition, ToolbarPositionData>,
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<T extends (...args: any[]) => 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();
}
}

View File

@@ -0,0 +1 @@
export * from './floating-toolbar.component';

View File

@@ -2,4 +2,6 @@ export * from './modal';
export * from './drawer'; export * from './drawer';
export * from './backdrop'; export * from './backdrop';
export * from './overlay-container'; export * from './overlay-container';
export * from './popover'; export * from './popover';
export * from './command-palette';
export * from './floating-toolbar';