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:
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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]
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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><ui-enhanced-table
|
||||||
|
[data]="employees"
|
||||||
|
[columns]="columns"
|
||||||
|
variant="default"
|
||||||
|
[enableColumnResize]="true"
|
||||||
|
[enableColumnReorder]="true"
|
||||||
|
[showFilters]="true"
|
||||||
|
[height]="500"
|
||||||
|
(sort)="handleSort($event)"
|
||||||
|
(filter)="handleFilter($event)">
|
||||||
|
</ui-enhanced-table></code></pre>
|
||||||
|
|
||||||
|
<h4>With Virtual Scrolling:</h4>
|
||||||
|
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code><ui-enhanced-table
|
||||||
|
[data]="largeDataset"
|
||||||
|
[columns]="columns"
|
||||||
|
[virtualScrollConfig]="{{ '{' }}
|
||||||
|
enabled: true,
|
||||||
|
itemHeight: 48,
|
||||||
|
bufferSize: 10
|
||||||
|
{{ '}' }}"
|
||||||
|
[height]="600">
|
||||||
|
</ui-enhanced-table></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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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'}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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><ui-tag-input
|
||||||
|
placeholder="Add tags..."
|
||||||
|
(tagAdd)="onTagAdd($event)"
|
||||||
|
(tagRemove)="onTagRemove($event)">
|
||||||
|
</ui-tag-input></code></pre>
|
||||||
|
|
||||||
|
<h4>With Constraints:</h4>
|
||||||
|
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code><ui-tag-input
|
||||||
|
placeholder="Add up to 5 tags"
|
||||||
|
[maxTags]="5"
|
||||||
|
[maxTagLength]="20"
|
||||||
|
[allowDuplicates]="false"
|
||||||
|
helpText="Each tag can be up to 20 characters"
|
||||||
|
(maxTagsReached)="onMaxTags()"
|
||||||
|
(duplicateTag)="onDuplicate($event)">
|
||||||
|
</ui-tag-input></code></pre>
|
||||||
|
|
||||||
|
<h4>Form Integration:</h4>
|
||||||
|
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code><form [formGroup]="myForm">
|
||||||
|
<ui-tag-input
|
||||||
|
formControlName="tags"
|
||||||
|
placeholder="Required tags"
|
||||||
|
[errorMessage]="getFieldError('tags')">
|
||||||
|
</ui-tag-input>
|
||||||
|
</form></code></pre>
|
||||||
|
|
||||||
|
<h4>Custom Separators:</h4>
|
||||||
|
<pre style="background: #fff; padding: 1rem; border-radius: 4px; overflow-x: auto;"><code><ui-tag-input
|
||||||
|
placeholder="Use space or dash to separate"
|
||||||
|
[separators]="[' ', '-']"
|
||||||
|
helpText="Tags separated by space or dash">
|
||||||
|
</ui-tag-input></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';
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -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 {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './fab-menu.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './icon-button.component';
|
||||||
@@ -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';
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './transfer-list.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './snackbar.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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';
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './tag-input.component';
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './bottom-navigation.component';
|
||||||
@@ -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';
|
||||||
@@ -131,7 +131,7 @@
|
|||||||
// ==========================================================================
|
// ==========================================================================
|
||||||
|
|
||||||
&--indent {
|
&--indent {
|
||||||
margin-left: $semantic-spacing-component-xl;
|
margin-left: $semantic-spacing-component-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
&--with-divider {
|
&--with-divider {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './stepper.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export * from './floating-toolbar.component';
|
||||||
@@ -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';
|
||||||
Reference in New Issue
Block a user