Initial commit - Angular library: ui-essentials

🎯 Implementation Complete!

This library has been extracted from the monorepo and is ready for Git submodule distribution.

Features:
- Standardized SCSS imports (no relative paths)
- Optimized public-api.ts exports
- Independent Angular library structure
- Ready for consumer integration as submodule

🚀 Generated with Claude Code (https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jules
2025-09-11 21:12:46 +10:00
commit 0a0cade343
302 changed files with 54198 additions and 0 deletions

1
README.md Normal file
View File

@@ -0,0 +1 @@
Repository: ui-essentials

7
ng-package.json Normal file
View File

@@ -0,0 +1,7 @@
{
"$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
"dest": "../../dist/ui-essentials",
"lib": {
"entryFile": "src/public-api.ts"
}
}

12
package.json Normal file
View File

@@ -0,0 +1,12 @@
{
"name": "ui-essentials",
"version": "0.0.1",
"peerDependencies": {
"@angular/common": "^19.2.0",
"@angular/core": "^19.2.0"
},
"dependencies": {
"tslib": "^2.3.0"
},
"sideEffects": false
}

View File

@@ -0,0 +1,247 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
// Tokens available globally via main application styles
.ui-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;
// Typography
font-family: $semantic-typography-font-family-sans;
font-weight: $semantic-typography-font-weight-medium;
text-align: center;
text-transform: none;
letter-spacing: $semantic-typography-letter-spacing-normal;
white-space: nowrap;
// 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
&--small {
height: $semantic-spacing-9; // 2.25rem
padding: 0 $semantic-spacing-3; // 0.75rem
font-size: $semantic-typography-font-size-sm;
line-height: $semantic-typography-line-height-tight;
min-width: $semantic-spacing-16; // 4rem
}
&--medium {
height: $semantic-spacing-11; // 2.75rem
padding: 0 $semantic-spacing-4; // 1rem
font-size: $semantic-typography-font-size-md;
line-height: $semantic-typography-line-height-normal;
min-width: $semantic-spacing-20; // 5rem
}
&--large {
height: $semantic-spacing-12; // 3rem + a bit
padding: 0 $semantic-spacing-6; // 1.5rem
font-size: $semantic-typography-font-size-lg;
line-height: $semantic-typography-line-height-normal;
min-width: $semantic-spacing-24; // 6rem
}
// Filled variant (primary)
&--filled {
background-color: $semantic-color-interactive-primary;
color: $semantic-color-on-brand-primary;
&:hover:not(:disabled) {
background-color: $semantic-color-brand-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-brand-primary;
outline-offset: 2px;
}
}
// Tonal variant
&--tonal {
background-color: $semantic-color-container-primary;
color: $semantic-color-on-container-primary;
&:hover:not(:disabled) {
background-color: $semantic-color-container-primary;
transform: translateY(-1px);
box-shadow: $semantic-shadow-button-hover;
filter: brightness(0.95);
}
&:active:not(:disabled) {
transform: scale(0.98);
}
&:focus-visible {
outline: 2px solid $semantic-color-brand-primary;
outline-offset: 2px;
}
}
// Outlined variant
&--outlined {
background-color: transparent;
color: $semantic-color-interactive-primary;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
&:hover:not(:disabled) {
background-color: $semantic-color-container-primary;
border-color: $semantic-color-interactive-primary;
transform: translateY(-1px);
}
&:active:not(:disabled) {
background-color: $semantic-color-container-primary;
transform: scale(0.98);
filter: brightness(0.9);
}
&:focus-visible {
outline: 2px solid $semantic-color-brand-primary;
outline-offset: 2px;
}
}
// States
&--disabled {
opacity: 0.38;
cursor: not-allowed;
pointer-events: none;
}
&--loading {
cursor: wait;
.button-content {
opacity: 0;
}
}
&--full-width {
width: 100%;
}
// Icon styles
.button-content {
display: flex;
align-items: center;
justify-content: center;
}
.button-icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
&--left {
margin-right: $semantic-spacing-2; // 0.5rem
}
&--right {
margin-left: $semantic-spacing-2; // 0.5rem
}
}
// Size-specific icon adjustments
&--small .button-icon {
font-size: $semantic-typography-font-size-xs;
&--left {
margin-right: $semantic-spacing-1-5; // 0.375rem
}
&--right {
margin-left: $semantic-spacing-1-5; // 0.375rem
}
}
&--medium .button-icon {
font-size: $semantic-typography-font-size-sm;
&--left {
margin-right: $semantic-spacing-2; // 0.5rem
}
&--right {
margin-left: $semantic-spacing-2; // 0.5rem
}
}
&--large .button-icon {
font-size: $semantic-typography-font-size-md;
&--left {
margin-right: $semantic-spacing-2-5; // 0.625rem
}
&--right {
margin-left: $semantic-spacing-2-5; // 0.625rem
}
}
// Loader styles
.button-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.button-spinner {
width: $semantic-spacing-4; // 1rem
height: $semantic-spacing-4; // 1rem
border: 2px solid currentColor;
border-top: 2px solid transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
opacity: 0.7;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// Ripple effect
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
transform: scale(0);
opacity: 0;
pointer-events: none;
transition: transform 0.3s ease, opacity 0.3s ease;
}
&:active:not(:disabled)::after {
transform: scale(1);
opacity: 1;
transition: none;
}
}

View File

@@ -0,0 +1,75 @@
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 ButtonVariant = 'filled' | 'tonal' | 'outlined';
export type ButtonSize = 'small' | 'medium' | 'large';
export type IconPosition = 'left' | 'right';
@Component({
selector: 'ui-button',
standalone: true,
imports: [CommonModule, FontAwesomeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button
[class]="buttonClasses"
[disabled]="disabled || loading"
[type]="type"
(click)="handleClick($event)"
>
@if (loading) {
<div class="button-loader">
<div class="button-spinner"></div>
</div>
} @else {
<div class="button-content">
@if (icon && iconPosition === 'left') {
<fa-icon [icon]="icon" class="button-icon button-icon--left"></fa-icon>
}
<span class="button-text">
<ng-content></ng-content>
</span>
@if (icon && iconPosition === 'right') {
<fa-icon [icon]="icon" class="button-icon button-icon--right"></fa-icon>
}
</div>
}
</button>
`,
styleUrl: './button.component.scss'
})
export class ButtonComponent {
@Input() variant: ButtonVariant = 'filled';
@Input() size: ButtonSize = 'medium';
@Input() disabled: boolean = false;
@Input() loading: boolean = false;
@Input() type: 'button' | 'submit' | 'reset' = 'button';
@Input() fullWidth: boolean = false;
@Input() class: string = '';
@Input() icon?: IconDefinition;
@Input() iconPosition: IconPosition = 'left';
@Output() clicked = new EventEmitter<Event>();
get buttonClasses(): string {
return [
'ui-button',
`ui-button--${this.variant}`,
`ui-button--${this.size}`,
this.disabled ? 'ui-button--disabled' : '',
this.loading ? 'ui-button--loading' : '',
this.fullWidth ? 'ui-button--full-width' : '',
this.icon ? 'ui-button--with-icon' : '',
this.icon ? `ui-button--icon-${this.iconPosition}` : '',
this.class
].filter(Boolean).join(' ');
}
handleClick(event: Event): void {
if (!this.disabled && !this.loading) {
this.clicked.emit(event);
}
}
}

View File

@@ -0,0 +1,475 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-fab-menu {
position: relative;
display: inline-flex;
z-index: $semantic-z-index-dropdown;
// Position variants
&--bottom-right {
position: fixed;
bottom: $semantic-spacing-layout-section-md;
right: $semantic-spacing-layout-section-md;
}
&--bottom-left {
position: fixed;
bottom: $semantic-spacing-layout-section-md;
left: $semantic-spacing-layout-section-md;
}
&--top-right {
position: fixed;
top: $semantic-spacing-layout-section-md;
right: $semantic-spacing-layout-section-md;
}
&--top-left {
position: fixed;
top: $semantic-spacing-layout-section-md;
left: $semantic-spacing-layout-section-md;
}
// Size variants
&--sm {
.ui-fab-menu__trigger {
width: $semantic-sizing-button-height-sm;
height: $semantic-sizing-button-height-sm;
.ui-fab-menu__trigger-icon {
width: $semantic-sizing-icon-button;
height: $semantic-sizing-icon-button;
font-size: map-get($semantic-typography-body-small, font-size);
}
}
.ui-fab-menu__item {
min-height: $semantic-sizing-button-height-sm;
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
}
}
&--md {
.ui-fab-menu__trigger {
width: $semantic-sizing-button-height-md;
height: $semantic-sizing-button-height-md;
.ui-fab-menu__trigger-icon {
width: $semantic-sizing-icon-button;
height: $semantic-sizing-icon-button;
font-size: map-get($semantic-typography-body-medium, font-size);
}
}
.ui-fab-menu__item {
min-height: $semantic-sizing-button-height-md;
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: map-get($semantic-typography-body-medium, font-weight);
line-height: map-get($semantic-typography-body-medium, line-height);
}
}
&--lg {
.ui-fab-menu__trigger {
width: $semantic-sizing-button-height-lg;
height: $semantic-sizing-button-height-lg;
.ui-fab-menu__trigger-icon {
width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
font-size: map-get($semantic-typography-body-large, font-size);
}
}
.ui-fab-menu__item {
min-height: $semantic-sizing-button-height-lg;
padding: $semantic-spacing-component-md $semantic-spacing-component-lg;
font-family: map-get($semantic-typography-body-large, font-family);
font-size: map-get($semantic-typography-body-large, font-size);
font-weight: map-get($semantic-typography-body-large, font-weight);
line-height: map-get($semantic-typography-body-large, line-height);
}
}
// Direction variants
&--up {
.ui-fab-menu__items {
flex-direction: column-reverse;
bottom: 100%;
margin-bottom: $semantic-spacing-component-sm;
}
.ui-fab-menu__item {
transform-origin: center bottom;
}
}
&--down {
.ui-fab-menu__items {
flex-direction: column;
top: 100%;
margin-top: $semantic-spacing-component-sm;
}
.ui-fab-menu__item {
transform-origin: center top;
}
}
&--left {
.ui-fab-menu__items {
flex-direction: row-reverse;
right: 100%;
margin-right: $semantic-spacing-component-sm;
align-items: center;
}
.ui-fab-menu__item {
transform-origin: right center;
}
}
&--right {
.ui-fab-menu__items {
flex-direction: row;
left: 100%;
margin-left: $semantic-spacing-component-sm;
align-items: center;
}
.ui-fab-menu__item {
transform-origin: left center;
}
}
// State variants
&--disabled {
.ui-fab-menu__trigger {
opacity: $semantic-opacity-disabled;
cursor: not-allowed;
pointer-events: none;
}
}
&--animating {
pointer-events: none;
}
}
.ui-fab-menu__trigger {
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: $semantic-border-width-1 solid transparent;
border-radius: $semantic-border-radius-full;
cursor: pointer;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
box-shadow: $semantic-shadow-elevation-2;
overflow: hidden;
// Color variants
&--primary {
background: $semantic-color-primary;
color: $semantic-color-on-primary;
border-color: $semantic-color-primary;
&:hover:not([disabled]) {
box-shadow: $semantic-shadow-elevation-3;
}
}
&--secondary {
background: $semantic-color-secondary;
color: $semantic-color-on-secondary;
border-color: $semantic-color-secondary;
&:hover:not([disabled]) {
box-shadow: $semantic-shadow-elevation-3;
}
}
&--success {
background: $semantic-color-success;
color: $semantic-color-on-success;
border-color: $semantic-color-success;
&:hover:not([disabled]) {
box-shadow: $semantic-shadow-elevation-3;
}
}
&--danger {
background: $semantic-color-danger;
color: $semantic-color-on-danger;
border-color: $semantic-color-danger;
&:hover:not([disabled]) {
box-shadow: $semantic-shadow-elevation-3;
}
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
&:active:not([disabled]) {
transform: scale(0.95);
box-shadow: $semantic-shadow-elevation-1;
}
&--active {
transform: rotate(45deg);
}
.ui-fab-menu__trigger-icon {
display: flex;
align-items: center;
justify-content: center;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
&--close {
transform: rotate(-45deg);
}
}
.ui-fab-menu__trigger-ripple {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: inherit;
overflow: hidden;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: currentColor;
transform: translate(-50%, -50%);
opacity: 0;
transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease;
}
.ui-fab-menu__trigger:active & {
&::after {
width: 100%;
height: 100%;
opacity: $semantic-opacity-subtle;
}
}
}
}
.ui-fab-menu__items {
position: absolute;
display: flex;
gap: $semantic-spacing-component-xs;
pointer-events: none;
z-index: 1;
.ui-fab-menu--open & {
pointer-events: auto;
}
}
.ui-fab-menu__item {
position: relative;
display: flex;
align-items: center;
gap: $semantic-spacing-component-xs;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
border-radius: $semantic-border-radius-md;
background: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
cursor: pointer;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
box-shadow: $semantic-shadow-elevation-1;
transform: scale(0);
opacity: 0;
overflow: hidden;
white-space: nowrap;
.ui-fab-menu--open & {
transform: scale(1);
opacity: 1;
@for $i from 1 through 10 {
&:nth-child(#{$i}) {
transition-delay: #{($i - 1) * 50ms};
}
}
}
.ui-fab-menu--animating:not(.ui-fab-menu--open) & {
transition-delay: 0ms;
}
// Color variants
&--primary {
background: $semantic-color-primary;
color: $semantic-color-on-primary;
border-color: $semantic-color-primary;
}
&--secondary {
background: $semantic-color-secondary;
color: $semantic-color-on-secondary;
border-color: $semantic-color-secondary;
}
&--success {
background: $semantic-color-success;
color: $semantic-color-on-success;
border-color: $semantic-color-success;
}
&--danger {
background: $semantic-color-danger;
color: $semantic-color-on-danger;
border-color: $semantic-color-danger;
}
&:hover:not([disabled]) {
box-shadow: $semantic-shadow-elevation-2;
transform: scale(1.05);
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
&:active:not([disabled]) {
transform: scale(0.98);
box-shadow: $semantic-shadow-elevation-1;
}
&--disabled {
opacity: $semantic-opacity-disabled;
cursor: not-allowed;
pointer-events: none;
}
.ui-fab-menu__item-icon {
display: flex;
align-items: center;
justify-content: center;
width: $semantic-sizing-icon-inline;
height: $semantic-sizing-icon-inline;
flex-shrink: 0;
}
.ui-fab-menu__item-label {
font-weight: $semantic-typography-font-weight-medium;
}
.ui-fab-menu__item-ripple {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
border-radius: inherit;
overflow: hidden;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: currentColor;
transform: translate(-50%, -50%);
opacity: 0;
transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease;
}
.ui-fab-menu__item:active & {
&::after {
width: 100%;
height: 100%;
opacity: $semantic-opacity-subtle;
}
}
}
}
.ui-fab-menu__backdrop {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: $semantic-color-backdrop;
backdrop-filter: blur(2px);
z-index: -1;
opacity: 0;
transition: opacity $semantic-motion-duration-fast $semantic-motion-easing-ease;
.ui-fab-menu--open & {
opacity: 1;
}
}
// Responsive design
@media (max-width: 768px) {
.ui-fab-menu {
&--bottom-right,
&--bottom-left,
&--top-right,
&--top-left {
margin: $semantic-spacing-component-sm;
}
}
.ui-fab-menu__item {
.ui-fab-menu__item-label {
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
}
}
}
// High contrast mode support
@media (prefers-contrast: high) {
.ui-fab-menu__trigger,
.ui-fab-menu__item {
border-width: $semantic-border-width-2;
}
}
// Reduced motion support
@media (prefers-reduced-motion: reduce) {
.ui-fab-menu__trigger,
.ui-fab-menu__item,
.ui-fab-menu__backdrop,
.ui-fab-menu__trigger-icon,
.ui-fab-menu__trigger-ripple::after,
.ui-fab-menu__item-ripple::after {
transition-duration: 0ms;
animation-duration: 0ms;
}
.ui-fab-menu__item {
.ui-fab-menu--open & {
transition-delay: 0ms;
}
}
}

View File

@@ -0,0 +1,285 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, HostListener, ElementRef, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
export type FabMenuSize = 'sm' | 'md' | 'lg';
export type FabMenuPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
export type FabMenuVariant = 'primary' | 'secondary' | 'success' | 'danger';
export type FabMenuDirection = 'up' | 'down' | 'left' | 'right' | 'auto';
export interface FabMenuItem {
id: string;
label: string;
icon?: string;
disabled?: boolean;
variant?: FabMenuVariant;
}
@Component({
selector: 'ui-fab-menu',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
[class]="getFabMenuClasses()">
<!-- Menu Items -->
@if (isOpen) {
<div class="ui-fab-menu__items" role="menu">
@for (item of menuItems; track item.id) {
<button
type="button"
[class]="getItemClasses(item)"
[disabled]="item.disabled || disabled"
[attr.aria-label]="item.label"
role="menuitem"
[tabindex]="isOpen ? 0 : -1"
(click)="handleItemClick(item, $event)"
(keydown)="handleItemKeydown(item, $event)">
@if (item.icon) {
<span class="ui-fab-menu__item-icon" [innerHTML]="item.icon"></span>
}
<span class="ui-fab-menu__item-label">{{ item.label }}</span>
<div class="ui-fab-menu__item-ripple"></div>
</button>
}
</div>
}
<!-- Main FAB Button -->
<button
type="button"
[class]="getTriggerClasses()"
[disabled]="disabled"
[attr.aria-expanded]="isOpen"
[attr.aria-haspopup]="true"
[attr.aria-label]="triggerLabel"
role="button"
(click)="toggle($event)"
(keydown)="handleTriggerKeydown($event)">
@if (isOpen && closeIcon) {
<span class="ui-fab-menu__trigger-icon ui-fab-menu__trigger-icon--close" [innerHTML]="closeIcon"></span>
} @else if (triggerIcon) {
<span class="ui-fab-menu__trigger-icon ui-fab-menu__trigger-icon--open" [innerHTML]="triggerIcon"></span>
}
<ng-content></ng-content>
<div class="ui-fab-menu__trigger-ripple"></div>
</button>
<!-- Backdrop -->
@if (isOpen && backdrop) {
<div
class="ui-fab-menu__backdrop"
(click)="close()"
role="presentation"></div>
}
</div>
`,
styleUrl: './fab-menu.component.scss'
})
export class FabMenuComponent {
@Input() size: FabMenuSize = 'md';
@Input() variant: FabMenuVariant = 'primary';
@Input() position: FabMenuPosition = 'bottom-right';
@Input() direction: FabMenuDirection = 'auto';
@Input() menuItems: FabMenuItem[] = [];
@Input() disabled = false;
@Input() triggerIcon?: string;
@Input() closeIcon?: string;
@Input() triggerLabel = 'Open menu';
@Input() backdrop = true;
@Input() closeOnItemClick = true;
@Input() closeOnOutsideClick = true;
@Output() opened = new EventEmitter<void>();
@Output() closed = new EventEmitter<void>();
@Output() itemClicked = new EventEmitter<{item: FabMenuItem, event: Event}>();
private elementRef = inject(ElementRef);
isOpen = false;
isAnimating = false;
get actualDirection(): FabMenuDirection {
if (this.direction !== 'auto') return this.direction;
// Auto-determine direction based on position
switch (this.position) {
case 'bottom-right':
case 'bottom-left':
return 'up';
case 'top-right':
case 'top-left':
return 'down';
default:
return 'up';
}
}
getFabMenuClasses(): string {
return [
'ui-fab-menu',
`ui-fab-menu--${this.size}`,
`ui-fab-menu--${this.variant}`,
`ui-fab-menu--${this.position}`,
`ui-fab-menu--${this.actualDirection}`,
this.isOpen ? 'ui-fab-menu--open' : '',
this.disabled ? 'ui-fab-menu--disabled' : '',
this.isAnimating ? 'ui-fab-menu--animating' : ''
].filter(Boolean).join(' ');
}
getTriggerClasses(): string {
return [
'ui-fab-menu__trigger',
`ui-fab-menu__trigger--${this.size}`,
`ui-fab-menu__trigger--${this.variant}`,
this.isOpen ? 'ui-fab-menu__trigger--active' : ''
].filter(Boolean).join(' ');
}
getItemClasses(item: FabMenuItem): string {
const variant = item.variant || this.variant;
return [
'ui-fab-menu__item',
`ui-fab-menu__item--${variant}`,
item.disabled ? 'ui-fab-menu__item--disabled' : ''
].filter(Boolean).join(' ');
}
@HostListener('document:click', ['$event'])
onDocumentClick(event: Event): void {
const target = event.target as Node;
if (this.closeOnOutsideClick && this.isOpen && target && !this.elementRef.nativeElement.contains(target)) {
this.close();
}
}
@HostListener('keydown.escape')
onEscapePress(): void {
if (this.isOpen) {
this.close();
}
}
toggle(event?: Event): void {
if (this.disabled) return;
if (this.isOpen) {
this.close();
} else {
this.open();
}
}
open(): void {
if (this.disabled || this.isOpen) return;
this.isAnimating = true;
this.isOpen = true;
this.opened.emit();
// Focus first menu item after animation
setTimeout(() => {
this.isAnimating = false;
this.focusFirstMenuItem();
}, 200);
}
close(): void {
if (!this.isOpen) return;
this.isAnimating = true;
this.isOpen = false;
this.closed.emit();
setTimeout(() => {
this.isAnimating = false;
}, 200);
}
handleItemClick(item: FabMenuItem, event: Event): void {
if (item.disabled || this.disabled) return;
this.itemClicked.emit({ item, event });
if (this.closeOnItemClick) {
this.close();
}
}
handleTriggerKeydown(event: KeyboardEvent): void {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
this.toggle();
break;
case 'ArrowUp':
if (this.actualDirection === 'up') {
event.preventDefault();
if (!this.isOpen) this.open();
}
break;
case 'ArrowDown':
if (this.actualDirection === 'down') {
event.preventDefault();
if (!this.isOpen) this.open();
}
break;
}
}
handleItemKeydown(item: FabMenuItem, event: KeyboardEvent): void {
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
this.handleItemClick(item, event);
break;
case 'ArrowUp':
event.preventDefault();
this.focusPreviousItem(event.target as HTMLElement);
break;
case 'ArrowDown':
event.preventDefault();
this.focusNextItem(event.target as HTMLElement);
break;
case 'Tab':
if (event.shiftKey) {
this.focusPreviousItem(event.target as HTMLElement);
} else {
this.focusNextItem(event.target as HTMLElement);
}
break;
}
}
private focusFirstMenuItem(): void {
const firstItem = this.elementRef.nativeElement.querySelector('.ui-fab-menu__item:not([disabled])');
if (firstItem) {
firstItem.focus();
}
}
private focusNextItem(currentElement: HTMLElement): void {
const items = Array.from(this.elementRef.nativeElement.querySelectorAll('.ui-fab-menu__item:not([disabled])'));
const currentIndex = items.indexOf(currentElement);
const nextIndex = (currentIndex + 1) % items.length;
(items[nextIndex] as HTMLElement).focus();
}
private focusPreviousItem(currentElement: HTMLElement): void {
const items = Array.from(this.elementRef.nativeElement.querySelectorAll('.ui-fab-menu__item:not([disabled])'));
const currentIndex = items.indexOf(currentElement);
const previousIndex = currentIndex === 0 ? items.length - 1 : currentIndex - 1;
(items[previousIndex] as HTMLElement).focus();
}
}

View File

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

View File

@@ -0,0 +1,303 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-fab {
// Reset and base styles
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 50%;
cursor: pointer;
outline: none;
position: relative;
overflow: hidden;
// Interaction states
user-select: none;
-webkit-tap-highlight-color: transparent;
box-sizing: border-box;
// Transitions
transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease-out;
// Size variants
&--small {
width: $semantic-spacing-10; // 2.5rem
height: $semantic-spacing-10; // 2.5rem
box-shadow: $semantic-shadow-card-hover;
.fab-icon {
font-size: $semantic-typography-font-size-lg; // 1.125rem
}
.fab-touch-overlay {
width: $semantic-spacing-4; // 1rem
height: $semantic-spacing-4; // 1rem
top: $semantic-spacing-3; // 0.75rem
left: $semantic-spacing-3; // 0.75rem
border-radius: $semantic-border-radius-sm;
}
.fab-spinner {
width: $semantic-spacing-4; // 1rem
height: $semantic-spacing-4; // 1rem
border-width: $semantic-border-width-2; // 2px (closest to 1.5px)
}
}
&--medium {
width: $semantic-spacing-14; // 3.5rem
height: $semantic-spacing-14; // 3.5rem
box-shadow: $semantic-shadow-card-active;
.fab-icon {
font-size: $semantic-typography-font-size-2xl; // 1.5rem
}
.fab-touch-overlay {
width: $semantic-spacing-5; // 1.25rem
height: $semantic-spacing-5; // 1.25rem
top: $semantic-spacing-4-5; // ~1.125rem (closest available)
left: $semantic-spacing-4-5; // ~1.125rem (closest available)
border-radius: $semantic-border-radius-lg;
}
.fab-spinner {
width: $semantic-spacing-5; // 1.25rem
height: $semantic-spacing-5; // 1.25rem
border-width: $semantic-border-width-2;
}
}
&--large {
width: $semantic-spacing-24; // 6rem
height: $semantic-spacing-24; // 6rem
box-shadow: $semantic-shadow-card-featured;
.fab-icon {
font-size: $semantic-typography-font-size-5xl; // 3rem (closest to 2.5rem)
}
.fab-touch-overlay {
width: $semantic-spacing-10; // 2.5rem
height: $semantic-spacing-10; // 2.5rem
top: $semantic-spacing-7; // 1.75rem
left: $semantic-spacing-7; // 1.75rem
border-radius: $semantic-border-radius-xl;
}
.fab-spinner {
width: $semantic-spacing-8; // 2rem
height: $semantic-spacing-8; // 2rem
border-width: $semantic-border-width-3;
}
}
// Color variants
&--primary {
background-color: $semantic-color-interactive-primary;
color: $semantic-color-on-brand-primary;
&:hover:not(:disabled) {
background-color: $semantic-color-brand-primary;
transform: $semantic-motion-hover-transform-scale-md;
box-shadow: $semantic-shadow-elevation-4;
}
}
&--secondary {
background-color: $semantic-color-interactive-secondary;
color: $semantic-color-on-brand-secondary;
&:hover:not(:disabled) {
background-color: $semantic-color-brand-secondary;
transform: $semantic-motion-hover-transform-scale-md;
box-shadow: $semantic-shadow-elevation-4;
}
}
&--tertiary {
background-color: $semantic-color-interactive-tertiary;
color: $semantic-color-on-brand-tertiary;
&:hover:not(:disabled) {
background-color: $semantic-color-brand-tertiary;
transform: $semantic-motion-hover-transform-scale-md;
box-shadow: $semantic-shadow-elevation-4;
}
}
// Position variants
&--bottom-right {
position: fixed;
bottom: $semantic-spacing-8; // 2rem
right: $semantic-spacing-6; // 1.5rem
z-index: $semantic-z-index-toast; // 1080
}
&--bottom-left {
position: fixed;
bottom: $semantic-spacing-8; // 2rem
left: $semantic-spacing-6; // 1.5rem
z-index: $semantic-z-index-toast; // 1080
}
&--top-right {
position: fixed;
top: $semantic-spacing-8; // 2rem
right: $semantic-spacing-6; // 1.5rem
z-index: $semantic-z-index-toast; // 1080
}
&--top-left {
position: fixed;
top: $semantic-spacing-8; // 2rem
left: $semantic-spacing-6; // 1.5rem
z-index: $semantic-z-index-toast; // 1080
}
// States
&:active:not(:disabled) {
transform: scale(0.92);
}
&:focus-visible {
box-shadow:
$semantic-shadow-button-focus,
$semantic-shadow-card-active;
}
&--disabled {
opacity: $semantic-opacity-disabled;
cursor: not-allowed;
pointer-events: none;
box-shadow: $semantic-shadow-elevation-1;
}
&--loading {
cursor: wait;
.fab-icon {
opacity: $semantic-opacity-invisible;
}
}
// Pulse effect
&--pulse {
overflow: visible;
.fab-pulse {
position: absolute;
width: 100%;
height: 100%;
background-color: inherit;
border-radius: inherit;
animation: fab-pulse 2s infinite;
z-index: -1;
}
}
@keyframes fab-pulse {
0% {
transform: scale(1);
opacity: 1;
}
100% {
transform: scale(1.4);
opacity: 0;
}
}
// Expand animation
&--expanding {
animation: fab-expand 300ms cubic-bezier(0, 0, 0.2, 1);
}
@keyframes fab-expand {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.2);
opacity: 0.8;
}
100% {
transform: scale(1);
opacity: 1;
}
}
// Touch overlay for ripple effect
.fab-touch-overlay {
position: absolute;
background-color: rgba(0, 0, 0, 0);
z-index: 1;
pointer-events: none;
}
// Icon container
.fab-icon {
position: relative;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
// Loader styles
.fab-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 3;
}
.fab-spinner {
border: 2px solid rgba(255, 255, 255, 0.3);
border-top: 2px solid white;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@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;
border-radius: inherit;
}
&:active:not(:disabled)::after {
transform: scale(1);
opacity: 1;
transition: none;
}
// Extended FAB support (for future use)
&--extended {
border-radius: $semantic-border-radius-pill; // 1.75rem equivalent
padding: 0 $semantic-spacing-4; // 1rem
width: auto;
min-width: $semantic-spacing-20; // 5rem
height: $semantic-spacing-14; // 3.5rem
.fab-icon {
margin-right: $semantic-spacing-1-5; // 0.375rem
}
}
}

View File

@@ -0,0 +1,79 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
export type FabSize = 'small' | 'medium' | 'large';
export type FabVariant = 'primary' | 'secondary' | 'tertiary';
export type FabPosition = 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'static';
@Component({
selector: 'ui-fab',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button
[class]="fabClasses"
[disabled]="disabled || loading"
[type]="type"
(click)="handleClick($event)"
>
@if (loading) {
<div class="fab-loader">
<div class="fab-spinner"></div>
</div>
} @else {
<div class="fab-icon">
<ng-content></ng-content>
</div>
}
@if (pulse) {
<div class="fab-pulse"></div>
}
<div class="fab-touch-overlay"></div>
</button>
`,
styleUrl: './fab.component.scss'
})
export class FabComponent {
@Input() variant: FabVariant = 'primary';
@Input() size: FabSize = 'medium';
@Input() disabled: boolean = false;
@Input() loading: boolean = false;
@Input() type: 'button' | 'submit' | 'reset' = 'button';
@Input() position: FabPosition = 'static';
@Input() pulse: boolean = false;
@Input() expandOnClick: boolean = false;
@Input() class: string = '';
@Output() clicked = new EventEmitter<Event>();
get fabClasses(): string {
return [
'ui-fab',
`ui-fab--${this.variant}`,
`ui-fab--${this.size}`,
`ui-fab--${this.position}`,
this.disabled ? 'ui-fab--disabled' : '',
this.loading ? 'ui-fab--loading' : '',
this.pulse ? 'ui-fab--pulse' : '',
this.expandOnClick ? 'ui-fab--expandable' : '',
this.class
].filter(Boolean).join(' ');
}
handleClick(event: Event): void {
if (!this.disabled && !this.loading) {
this.clicked.emit(event);
if (this.expandOnClick) {
// Add animation class for expand effect
(event.target as HTMLElement).classList.add('ui-fab--expanding');
setTimeout(() => {
(event.target as HTMLElement).classList.remove('ui-fab--expanding');
}, 300);
}
}
}
}

View File

@@ -0,0 +1,141 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-ghost-button {
// Reset and base styles
display: inline-flex;
align-items: center;
justify-content: center;
border: $semantic-border-width-1 solid transparent;
background: transparent;
cursor: pointer;
text-decoration: none;
outline: none;
position: relative;
overflow: hidden;
border-radius: $semantic-border-radius-lg;
// Typography
font-family: $semantic-typography-font-family-sans;
font-weight: $semantic-typography-font-weight-medium;
text-align: center;
text-transform: none;
letter-spacing: $semantic-typography-letter-spacing-wide;
white-space: nowrap;
color: $semantic-color-text-secondary;
// Interaction states
user-select: none;
-webkit-tap-highlight-color: transparent;
box-sizing: border-box;
// Transitions
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-out;
// Size variants
&--small {
height: $semantic-spacing-9; // 2.25rem
padding: 0 $semantic-spacing-2; // 0.5rem
font-size: $semantic-typography-font-size-sm;
line-height: $semantic-typography-line-height-normal;
min-width: $semantic-spacing-16; // 4rem
}
&--medium {
height: $semantic-spacing-11; // 2.75rem
padding: 0 $semantic-spacing-4; // 1rem
font-size: $semantic-typography-font-size-md;
line-height: $semantic-typography-line-height-normal;
min-width: $semantic-spacing-20; // 5rem
}
&--large {
height: $semantic-spacing-12; // 3rem (closest to 3.25rem)
padding: 0 $semantic-spacing-6; // 1.5rem
font-size: $semantic-typography-font-size-lg;
line-height: $semantic-typography-line-height-normal;
min-width: $semantic-spacing-24; // 6rem
}
// Hover states
&:hover:not(:disabled) {
background-color: $semantic-color-surface-container;
color: $semantic-color-text-primary;
border-color: $semantic-color-border-secondary;
transform: $semantic-motion-hover-transform-lift-sm;
box-shadow: $semantic-shadow-button-hover;
}
&:active:not(:disabled) {
background-color: $semantic-color-surface-high;
transform: scale(0.98);
box-shadow: $semantic-shadow-none;
}
&:focus-visible {
background-color: $semantic-color-surface-container;
border-color: $semantic-color-border-focus;
box-shadow: $semantic-shadow-button-focus;
}
// States
&--disabled {
opacity: $semantic-opacity-disabled;
cursor: not-allowed;
pointer-events: none;
}
&--loading {
cursor: wait;
.ghost-button-content {
opacity: $semantic-opacity-invisible;
}
}
&--full-width {
width: 100%;
}
// Loader styles
.ghost-button-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.ghost-button-spinner {
width: $semantic-spacing-4; // 1rem
height: $semantic-spacing-4; // 1rem
border: $semantic-border-width-2 solid rgba($semantic-color-brand-primary, 0.3);
border-top: $semantic-border-width-2 solid $semantic-color-brand-primary;
border-radius: $semantic-border-radius-full;
animation: spin 1s $semantic-motion-easing-linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// Ripple effect (basic implementation)
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba($semantic-color-brand-primary, 0.08) 0%, transparent 70%);
transform: scale(0);
opacity: $semantic-opacity-invisible;
pointer-events: none;
transition: transform $semantic-motion-duration-normal $semantic-motion-easing-ease, opacity $semantic-motion-duration-normal $semantic-motion-easing-ease;
border-radius: inherit;
}
&:active:not(:disabled)::after {
transform: scale(1);
opacity: $semantic-opacity-opaque;
transition: none;
}
}

View File

@@ -0,0 +1,55 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
export type GhostButtonSize = 'small' | 'medium' | 'large';
@Component({
selector: 'ui-ghost-button',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button
[class]="buttonClasses"
[disabled]="disabled || loading"
[type]="type"
(click)="handleClick($event)"
>
@if (loading) {
<div class="ghost-button-loader">
<div class="ghost-button-spinner"></div>
</div>
} @else {
<ng-content></ng-content>
}
</button>
`,
styleUrl: './ghost-button.component.scss'
})
export class GhostButtonComponent {
@Input() size: GhostButtonSize = 'medium';
@Input() disabled: boolean = false;
@Input() loading: boolean = false;
@Input() type: 'button' | 'submit' | 'reset' = 'button';
@Input() fullWidth: boolean = false;
@Input() class: string = '';
@Output() clicked = new EventEmitter<Event>();
get buttonClasses(): string {
return [
'ui-ghost-button',
`ui-ghost-button--${this.size}`,
this.disabled ? 'ui-ghost-button--disabled' : '',
this.loading ? 'ui-ghost-button--loading' : '',
this.fullWidth ? 'ui-ghost-button--full-width' : '',
this.class
].filter(Boolean).join(' ');
}
handleClick(event: Event): void {
if (!this.disabled && !this.loading) {
this.clicked.emit(event);
}
}
}

View File

@@ -0,0 +1,203 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-icon-button {
// Reset and base styles
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
border-radius: $semantic-border-radius-md;
cursor: pointer;
text-decoration: none;
outline: none;
position: relative;
overflow: hidden;
// Make it square/circular
aspect-ratio: 1;
flex-shrink: 0;
// Interaction states
user-select: none;
-webkit-tap-highlight-color: transparent;
box-sizing: border-box;
// Transitions
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
// Size variants - square buttons with proper touch targets
&--small {
width: $semantic-spacing-component-padding-xl; // 1.5rem
height: $semantic-spacing-component-padding-xl;
border-radius: $semantic-border-radius-sm;
.icon-button-icon {
font-size: $semantic-spacing-component-md; // 0.75rem
}
}
&--medium {
width: $semantic-spacing-interactive-touch-target; // 2.75rem
height: $semantic-spacing-interactive-touch-target;
border-radius: $semantic-border-radius-md;
.icon-button-icon {
font-size: $semantic-spacing-component-lg; // 1rem
}
}
&--large {
width: $semantic-spacing-layout-section-xs; // 2rem but we need larger
height: $semantic-spacing-layout-section-xs;
border-radius: $semantic-border-radius-lg;
.icon-button-icon {
font-size: $semantic-spacing-component-xl; // 1.5rem
}
}
// Filled variant (primary)
&--filled {
background-color: $semantic-color-brand-primary;
color: $semantic-color-on-brand-primary;
&:hover:not(:disabled) {
background-color: $semantic-color-brand-primary;
transform: translateY(-1px);
box-shadow: $semantic-shadow-elevation-2;
filter: brightness(1.1);
}
&:active:not(:disabled) {
transform: scale(0.95);
box-shadow: $semantic-shadow-elevation-1;
}
&:focus-visible {
outline: 2px solid $semantic-color-brand-primary;
outline-offset: 2px;
}
}
// Tonal variant
&--tonal {
background-color: $semantic-color-surface-interactive;
color: $semantic-color-text-primary;
&:hover:not(:disabled) {
background-color: $semantic-color-surface-interactive;
transform: translateY(-1px);
box-shadow: $semantic-shadow-elevation-2;
filter: brightness(0.95);
}
&:active:not(:disabled) {
transform: scale(0.95);
}
&:focus-visible {
outline: 2px solid $semantic-color-brand-primary;
outline-offset: 2px;
}
}
// Outlined variant
&--outlined {
background-color: transparent;
color: $semantic-color-brand-primary;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
&:hover:not(:disabled) {
background-color: $semantic-color-surface-interactive;
border-color: $semantic-color-brand-primary;
transform: translateY(-1px);
}
&:active:not(:disabled) {
background-color: $semantic-color-surface-interactive;
transform: scale(0.95);
filter: brightness(0.9);
}
&:focus-visible {
outline: 2px solid $semantic-color-brand-primary;
outline-offset: 2px;
}
}
// States
&--disabled {
opacity: $semantic-opacity-disabled;
cursor: not-allowed;
pointer-events: none;
}
&--loading {
cursor: wait;
.icon-button-icon {
opacity: 0;
}
}
&--pressed {
background-color: $semantic-color-surface-selected;
color: $semantic-color-text-primary;
&.ui-icon-button--outlined {
border-color: $semantic-color-brand-primary;
}
}
// Icon styles
.icon-button-icon {
display: inline-flex;
align-items: center;
justify-content: center;
transition: opacity 200ms ease;
}
// Loader styles
.icon-button-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.icon-button-spinner {
width: $semantic-spacing-component-lg; // 1rem
height: $semantic-spacing-component-lg;
border: 2px solid currentColor;
border-top: 2px solid transparent;
border-radius: 50%;
animation: spin 1s linear infinite;
opacity: 0.7;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// Ripple effect
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba(255, 255, 255, 0.3) 0%, transparent 70%);
transform: scale(0);
opacity: 0;
pointer-events: none;
transition: transform 0.3s ease, opacity 0.3s ease;
}
&:active:not(:disabled)::after {
transform: scale(1);
opacity: 1;
transition: none;
}
}

View File

@@ -0,0 +1,66 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
export type IconButtonVariant = 'filled' | 'tonal' | 'outlined';
export type IconButtonSize = 'small' | 'medium' | 'large';
@Component({
selector: 'ui-icon-button',
standalone: true,
imports: [CommonModule, FontAwesomeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button
[class]="buttonClasses"
[disabled]="disabled || loading"
[type]="type"
[attr.aria-label]="ariaLabel"
[attr.aria-pressed]="pressed"
[attr.title]="title"
(click)="handleClick($event)"
>
@if (loading) {
<div class="icon-button-loader">
<div class="icon-button-spinner"></div>
</div>
} @else {
<fa-icon [icon]="icon" class="icon-button-icon"></fa-icon>
}
</button>
`,
styleUrl: './icon-button.component.scss'
})
export class IconButtonComponent {
@Input({ required: true }) icon!: IconDefinition;
@Input() variant: IconButtonVariant = 'filled';
@Input() size: IconButtonSize = 'medium';
@Input() disabled: boolean = false;
@Input() loading: boolean = false;
@Input() pressed: boolean = false;
@Input() type: 'button' | 'submit' | 'reset' = 'button';
@Input() class: string = '';
@Input() ariaLabel: string = '';
@Input() title: string = '';
@Output() clicked = new EventEmitter<Event>();
get buttonClasses(): string {
return [
'ui-icon-button',
`ui-icon-button--${this.variant}`,
`ui-icon-button--${this.size}`,
this.disabled ? 'ui-icon-button--disabled' : '',
this.loading ? 'ui-icon-button--loading' : '',
this.pressed ? 'ui-icon-button--pressed' : '',
this.class
].filter(Boolean).join(' ');
}
handleClick(event: Event): void {
if (!this.disabled && !this.loading) {
this.clicked.emit(event);
}
}
}

View File

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

View File

@@ -0,0 +1,5 @@
export * from './simple-button.component';
export * from './button.component';
export * from './text-button.component';
export * from './ghost-button.component';
export * from './fab.component';

View File

@@ -0,0 +1,8 @@
export * from './button.component';
export * from './text-button.component';
export * from './ghost-button.component';
export * from './fab.component';
export * from './fab-menu';
export * from './simple-button.component';
export * from './split-button';
export * from './icon-button';

View File

@@ -0,0 +1,91 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
// Tokens available globally via main application styles
.ui-simple-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;
box-sizing: border-box;
// Typography
font-family: $semantic-typography-font-family-sans;
font-weight: $semantic-typography-font-weight-medium;
font-size: $semantic-typography-font-size-sm;
line-height: $semantic-typography-line-height-normal;
text-align: center;
white-space: nowrap;
// Default sizing
height: $semantic-spacing-10; // 2.5rem
padding: 0 $semantic-spacing-4; // 1rem
min-width: $semantic-spacing-20; // 5rem
// Interaction states
user-select: none;
-webkit-tap-highlight-color: transparent;
// Transitions
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-out;
// Primary variant
&--primary {
background-color: $semantic-color-interactive-primary;
color: $semantic-color-on-brand-primary;
&:hover:not(:disabled) {
background-color: $semantic-color-brand-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-brand-primary;
outline-offset: 2px;
}
}
// Secondary variant
&--secondary {
background-color: $semantic-color-container-primary;
color: $semantic-color-on-container-primary;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
&:hover:not(:disabled) {
background-color: $semantic-color-container-primary;
border-color: $semantic-color-interactive-primary;
transform: translateY(-1px);
filter: brightness(0.95);
}
&:active:not(:disabled) {
background-color: $semantic-color-container-primary;
transform: scale(0.98);
filter: brightness(0.9);
}
&:focus-visible {
outline: 2px solid $semantic-color-brand-primary;
outline-offset: 2px;
}
}
// Disabled state
&:disabled {
opacity: $semantic-opacity-disabled;
cursor: not-allowed;
pointer-events: none;
}
}

View File

@@ -0,0 +1,41 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
@Component({
selector: 'ui-simple-button',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button
[class]="buttonClasses"
[disabled]="disabled"
[type]="type"
(click)="handleClick($event)"
>
<ng-content></ng-content>
</button>
`,
styleUrl: './simple-button.component.scss'
})
export class SimpleButtonComponent {
@Input() variant: 'primary' | 'secondary' = 'primary';
@Input() disabled: boolean = false;
@Input() type: 'button' | 'submit' | 'reset' = 'button';
@Output() clicked = new EventEmitter<Event>();
get buttonClasses(): string {
return [
'ui-simple-button',
`ui-simple-button--${this.variant}`,
this.disabled ? 'ui-simple-button--disabled' : ''
].filter(Boolean).join(' ');
}
handleClick(event: Event): void {
if (!this.disabled) {
this.clicked.emit(event);
}
}
}

View File

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

View File

@@ -0,0 +1,463 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-split-button {
// Reset and base styles
display: inline-flex;
align-items: center;
position: relative;
border-radius: $semantic-border-radius-md;
overflow: hidden;
// Size variants
&--small {
height: $semantic-spacing-9; // 2.25rem
.ui-split-button__primary,
.ui-split-button__dropdown {
font-size: $semantic-typography-font-size-sm;
line-height: $semantic-typography-line-height-tight;
}
}
&--medium {
height: $semantic-spacing-11; // 2.75rem
.ui-split-button__primary,
.ui-split-button__dropdown {
font-size: $semantic-typography-font-size-md;
line-height: $semantic-typography-line-height-normal;
}
}
&--large {
height: $semantic-spacing-12; // 3rem
.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 {
width: 100%;
}
// Disabled state
&--disabled {
opacity: 0.38;
cursor: not-allowed;
pointer-events: none;
}
// Primary button part
&__primary {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
text-decoration: none;
outline: none;
flex: 1;
height: 100%; // Match the container height
// Default colors (filled variant)
background-color: $semantic-color-interactive-primary;
color: $semantic-color-on-primary;
// Typography
font-family: $semantic-typography-font-family-sans;
font-size: $semantic-typography-font-size-md; // Default medium size
font-weight: $semantic-typography-font-weight-medium;
line-height: $semantic-typography-line-height-normal; // Default medium line-height
text-align: center;
text-transform: none;
letter-spacing: $semantic-typography-letter-spacing-normal;
white-space: nowrap;
// 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);
// Default hover state
&:hover:not(:disabled) {
background-color: $semantic-color-primary;
transform: translateY(-1px);
box-shadow: $semantic-shadow-button-hover;
}
&:active:not(:disabled) {
transform: scale(0.98);
box-shadow: $semantic-shadow-button-active;
}
&:focus-visible {
outline: 2px solid $semantic-color-primary;
outline-offset: 2px;
}
// Size-specific padding
.ui-split-button--small & {
padding: 0 $semantic-spacing-3; // 0.75rem
}
.ui-split-button--medium & {
padding: 0 $semantic-spacing-4; // 1rem
}
.ui-split-button--large & {
padding: 0 $semantic-spacing-6; // 1.5rem
}
}
// Dropdown button part
&__dropdown {
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
cursor: pointer;
outline: none;
position: relative;
// Default colors (filled variant)
background-color: $semantic-color-interactive-primary;
color: $semantic-color-on-primary;
// Typography
font-family: $semantic-typography-font-family-sans;
font-size: $semantic-typography-font-size-md; // Default medium size
font-weight: $semantic-typography-font-weight-medium;
line-height: $semantic-typography-line-height-normal; // Default medium line-height
// Default size (medium) - defaults only
width: $semantic-spacing-9; // Default to medium size
padding: 0 $semantic-spacing-2-5; // Default medium padding
// Interaction states
user-select: none;
-webkit-tap-highlight-color: transparent;
box-sizing: border-box;
// Transitions
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
// Default hover state
&:hover:not(:disabled) {
background-color: $semantic-color-primary;
transform: translateY(-1px);
box-shadow: $semantic-shadow-button-hover;
}
&:active:not(:disabled) {
transform: scale(0.98);
box-shadow: $semantic-shadow-button-active;
}
&:focus-visible {
outline: 2px solid $semantic-color-primary;
outline-offset: 2px;
}
// Divider between buttons
&::before {
content: '';
position: absolute;
left: 0;
top: 20%;
height: 60%;
width: 1px;
background-color: currentColor;
opacity: 0.3;
}
}
// Size-specific dropdown dimensions (must come after default styles)
&--small &__dropdown {
width: $semantic-spacing-8; // 2rem - slightly smaller than height
height: $semantic-spacing-9; // Match small container height
padding: 0 $semantic-spacing-2; // 0.5rem
}
&--medium &__dropdown {
width: $semantic-spacing-9; // 2.25rem - smaller than height
height: $semantic-spacing-11; // Match medium container height
padding: 0 $semantic-spacing-2-5; // 0.625rem
}
&--large &__dropdown {
width: $semantic-spacing-10; // 2.5rem - smaller than height
height: $semantic-spacing-12; // Match large container height
padding: 0 $semantic-spacing-3; // 0.75rem
}
// Content wrapper
&__content {
display: flex;
align-items: center;
justify-content: center;
}
// Icon styles
&__icon {
display: inline-flex;
align-items: center;
flex-shrink: 0;
&--left {
margin-right: $semantic-spacing-2; // 0.5rem
}
&--right {
margin-left: $semantic-spacing-2; // 0.5rem
}
// Size-specific adjustments
.ui-split-button--small & {
font-size: $semantic-typography-font-size-xs;
&--left {
margin-right: $semantic-spacing-1-5; // 0.375rem
}
&--right {
margin-left: $semantic-spacing-1-5; // 0.375rem
}
}
.ui-split-button--medium & {
font-size: $semantic-typography-font-size-sm;
}
.ui-split-button--large & {
font-size: $semantic-typography-font-size-md;
&--left {
margin-right: $semantic-spacing-2-5; // 0.625rem
}
&--right {
margin-left: $semantic-spacing-2-5; // 0.625rem
}
}
}
// Dropdown arrow
&__arrow {
display: inline-flex;
align-items: center;
font-size: 0.875em; // Slightly larger for better visibility
transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
}
// Dropdown menu
&__menu {
position: absolute;
top: 100%;
left: 0;
min-width: 100%;
background: $semantic-color-surface-elevated;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
border-radius: $semantic-border-radius-md;
box-shadow: $semantic-shadow-dropdown;
z-index: $semantic-z-index-dropdown;
opacity: 0;
visibility: hidden;
transform: translateY(-$semantic-spacing-2);
transition: all 200ms cubic-bezier(0.4, 0, 0.2, 1);
&--open {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
}
// Menu item
&__menu-item {
display: flex;
align-items: center;
width: 100%;
padding: $semantic-spacing-3 $semantic-spacing-4;
border: none;
background: transparent;
color: $semantic-color-text-primary;
font-family: $semantic-typography-font-family-sans;
font-size: $semantic-typography-font-size-sm;
font-weight: $semantic-typography-font-weight-normal;
text-align: left;
cursor: pointer;
transition: background-color 200ms cubic-bezier(0.4, 0, 0.2, 1);
&:hover {
background: $semantic-color-surface-container;
}
&:first-child {
border-top-left-radius: $semantic-border-radius-md;
border-top-right-radius: $semantic-border-radius-md;
}
&:last-child {
border-bottom-left-radius: $semantic-border-radius-md;
border-bottom-right-radius: $semantic-border-radius-md;
}
}
// Filled variant (primary)
&--filled {
.ui-split-button__primary,
.ui-split-button__dropdown {
background-color: $semantic-color-interactive-primary;
color: $semantic-color-on-primary;
&: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;
}
}
.ui-split-button__dropdown:hover .ui-split-button__arrow {
transform: rotate(180deg);
}
}
// Tonal variant
&--tonal {
.ui-split-button__primary,
.ui-split-button__dropdown {
background-color: $semantic-color-surface-container;
color: $semantic-color-text-primary;
&:hover:not(:disabled) {
background-color: $semantic-color-surface-container;
transform: translateY(-1px);
box-shadow: $semantic-shadow-button-hover;
filter: brightness(0.95);
}
&:active:not(:disabled) {
transform: scale(0.98);
}
&:focus-visible {
outline: 2px solid $semantic-color-primary;
outline-offset: 2px;
}
}
.ui-split-button__dropdown:hover .ui-split-button__arrow {
transform: rotate(180deg);
}
}
// Outlined variant
&--outlined {
border: $semantic-border-width-1 solid $semantic-color-border-primary;
.ui-split-button__primary,
.ui-split-button__dropdown {
background-color: transparent;
color: $semantic-color-interactive-primary;
&:hover:not(:disabled) {
background-color: $semantic-color-surface-container;
transform: translateY(-1px);
}
&:active:not(:disabled) {
background-color: $semantic-color-surface-container;
transform: scale(0.98);
filter: brightness(0.9);
}
&:focus-visible {
outline: 2px solid $semantic-color-primary;
outline-offset: 2px;
}
}
.ui-split-button__dropdown:hover .ui-split-button__arrow {
transform: rotate(180deg);
}
}
// Loading state
&--loading {
.ui-split-button__primary {
cursor: wait;
.ui-split-button__content {
opacity: 0;
}
}
.ui-split-button__dropdown {
pointer-events: none;
opacity: 0.5;
}
}
// Loader
&__loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
&__spinner {
width: $semantic-spacing-4; // 1rem
height: $semantic-spacing-4; // 1rem
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
.ui-split-button__primary::after,
.ui-split-button__dropdown::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;
}
.ui-split-button__primary:active:not(:disabled)::after,
.ui-split-button__dropdown:active:not(:disabled)::after {
transform: scale(1);
opacity: 1;
transition: none;
}
}

View File

@@ -0,0 +1,169 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, HostListener } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
import { faChevronDown } from '@fortawesome/free-solid-svg-icons';
export type SplitButtonVariant = 'filled' | 'tonal' | 'outlined';
export type SplitButtonSize = 'small' | 'medium' | 'large';
export type SplitButtonIconPosition = 'left' | 'right';
export interface SplitButtonMenuItem {
label: string;
value?: any;
icon?: IconDefinition;
disabled?: boolean;
}
@Component({
selector: 'ui-split-button',
standalone: true,
imports: [CommonModule, FontAwesomeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-split-button"
[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' : '')">
<!-- Primary button -->
<button
class="ui-split-button__primary"
[disabled]="disabled || loading"
[type]="type"
(click)="handlePrimaryClick($event)">
@if (loading) {
<div class="ui-split-button__loader">
<div class="ui-split-button__spinner"></div>
</div>
} @else {
<div class="ui-split-button__content">
@if (icon && iconPosition === 'left') {
<fa-icon [icon]="icon" class="ui-split-button__icon ui-split-button__icon--left"></fa-icon>
}
<span class="ui-split-button__text">
<ng-content></ng-content>
</span>
@if (icon && iconPosition === 'right') {
<fa-icon [icon]="icon" class="ui-split-button__icon ui-split-button__icon--right"></fa-icon>
}
</div>
}
</button>
<!-- Dropdown button -->
<button
class="ui-split-button__dropdown"
[disabled]="disabled || loading"
type="button"
[attr.aria-expanded]="isMenuOpen"
[attr.aria-haspopup]="'menu'"
(click)="handleDropdownClick($event)">
<fa-icon
[icon]="faChevronDown"
class="ui-split-button__arrow">
</fa-icon>
</button>
<!-- Dropdown menu -->
@if (menuItems && menuItems.length > 0) {
<div
class="ui-split-button__menu"
[class.ui-split-button__menu--open]="isMenuOpen"
role="menu"
[attr.aria-hidden]="!isMenuOpen">
@for (item of menuItems; track item.label) {
<button
class="ui-split-button__menu-item"
role="menuitem"
[disabled]="item.disabled"
(click)="handleMenuItemClick($event, item)">
@if (item.icon) {
<fa-icon
[icon]="item.icon"
class="ui-split-button__icon ui-split-button__icon--left">
</fa-icon>
}
{{ item.label }}
</button>
}
</div>
}
</div>
`,
styleUrl: './split-button.component.scss'
})
export class SplitButtonComponent {
@Input() variant: SplitButtonVariant = 'filled';
@Input() size: SplitButtonSize = 'medium';
@Input() disabled: boolean = false;
@Input() loading: boolean = false;
@Input() type: 'button' | 'submit' | 'reset' = 'button';
@Input() fullWidth: boolean = false;
@Input() class: string = '';
@Input() icon?: IconDefinition;
@Input() iconPosition: SplitButtonIconPosition = 'left';
@Input() menuItems: SplitButtonMenuItem[] = [];
@Output() primaryClicked = new EventEmitter<Event>();
@Output() menuItemClicked = new EventEmitter<{ event: Event; item: SplitButtonMenuItem }>();
@Output() dropdownToggled = new EventEmitter<boolean>();
isMenuOpen = false;
faChevronDown = faChevronDown;
@HostListener('document:click', ['$event'])
onDocumentClick(event: Event): void {
const target = event.target as Element;
const splitButton = target.closest('.ui-split-button');
if (!splitButton || !splitButton.contains(target)) {
this.closeMenu();
}
}
@HostListener('document:keydown', ['$event'])
onDocumentKeydown(event: KeyboardEvent): void {
if (event.key === 'Escape' && this.isMenuOpen) {
this.closeMenu();
}
}
handlePrimaryClick(event: Event): void {
if (!this.disabled && !this.loading) {
this.primaryClicked.emit(event);
this.closeMenu();
}
}
handleDropdownClick(event: Event): void {
if (!this.disabled && !this.loading) {
event.stopPropagation();
this.toggleMenu();
}
}
handleMenuItemClick(event: Event, item: SplitButtonMenuItem): void {
if (!item.disabled) {
event.stopPropagation();
this.menuItemClicked.emit({ event, item });
this.closeMenu();
}
}
private toggleMenu(): void {
this.isMenuOpen = !this.isMenuOpen;
this.dropdownToggled.emit(this.isMenuOpen);
}
private closeMenu(): void {
if (this.isMenuOpen) {
this.isMenuOpen = false;
this.dropdownToggled.emit(false);
}
}
}

View File

@@ -0,0 +1,132 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-text-button {
// Reset and base styles
display: inline-flex;
align-items: center;
justify-content: center;
border: none;
background: transparent;
cursor: pointer;
text-decoration: none;
outline: none;
position: relative;
overflow: hidden;
// Typography
font-family: $semantic-typography-font-family-sans;
font-weight: $semantic-typography-font-weight-medium;
text-align: center;
text-transform: none;
letter-spacing: $semantic-typography-letter-spacing-wide;
white-space: nowrap;
color: $semantic-color-interactive-primary;
// Interaction states
user-select: none;
-webkit-tap-highlight-color: transparent;
box-sizing: border-box;
border-radius: $semantic-border-radius-sm;
// Transitions
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease-out;
// Size variants
&--small {
height: $semantic-spacing-8; // 2rem
padding: 0 $semantic-spacing-1; // 0.25rem
font-size: $semantic-typography-font-size-sm;
line-height: $semantic-typography-line-height-normal;
min-width: $semantic-spacing-12; // 3rem
}
&--medium {
height: $semantic-spacing-10; // 2.5rem
padding: 0 $semantic-spacing-2; // 0.5rem
font-size: $semantic-typography-font-size-md;
line-height: $semantic-typography-line-height-normal;
min-width: $semantic-spacing-16; // 4rem
}
&--large {
height: $semantic-spacing-12; // 3rem
padding: 0 $semantic-spacing-3; // 0.75rem
font-size: $semantic-typography-font-size-lg;
line-height: $semantic-typography-line-height-normal;
min-width: $semantic-spacing-20; // 5rem
}
// Hover states
&:hover:not(:disabled) {
background-color: rgba($semantic-color-brand-primary, 0.08);
color: $semantic-color-brand-primary;
}
&:active:not(:disabled) {
background-color: rgba($semantic-color-brand-primary, 0.12);
transform: scale(0.98);
}
&:focus-visible {
background-color: rgba($semantic-color-brand-primary, 0.08);
box-shadow: $semantic-shadow-button-focus;
}
// States
&--disabled {
opacity: $semantic-opacity-disabled;
cursor: not-allowed;
pointer-events: none;
}
&--loading {
cursor: wait;
.text-button-content {
opacity: $semantic-opacity-invisible;
}
}
// Loader styles
.text-button-loader {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
.text-button-spinner {
width: $semantic-spacing-3-5; // 0.875rem
height: $semantic-spacing-3-5; // 0.875rem
border: $semantic-border-width-2 solid rgba($semantic-color-brand-primary, 0.3); // closest to 1.5px
border-top: $semantic-border-width-2 solid $semantic-color-brand-primary;
border-radius: $semantic-border-radius-full;
animation: spin 1s $semantic-motion-easing-linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// Ripple effect (basic implementation)
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: radial-gradient(circle, rgba($semantic-color-brand-primary, 0.12) 0%, transparent 70%);
transform: scale(0);
opacity: $semantic-opacity-invisible;
pointer-events: none;
transition: transform $semantic-motion-duration-normal $semantic-motion-easing-ease, opacity $semantic-motion-duration-normal $semantic-motion-easing-ease;
border-radius: inherit;
}
&:active:not(:disabled)::after {
transform: scale(1);
opacity: $semantic-opacity-opaque;
transition: none;
}
}

View File

@@ -0,0 +1,53 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
export type TextButtonSize = 'small' | 'medium' | 'large';
@Component({
selector: 'ui-text-button',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<button
[class]="buttonClasses"
[disabled]="disabled || loading"
[type]="type"
(click)="handleClick($event)"
>
@if (loading) {
<div class="text-button-loader">
<div class="text-button-spinner"></div>
</div>
} @else {
<ng-content></ng-content>
}
</button>
`,
styleUrl: './text-button.component.scss'
})
export class TextButtonComponent {
@Input() size: TextButtonSize = 'medium';
@Input() disabled: boolean = false;
@Input() loading: boolean = false;
@Input() type: 'button' | 'submit' | 'reset' = 'button';
@Input() class: string = '';
@Output() clicked = new EventEmitter<Event>();
get buttonClasses(): string {
return [
'ui-text-button',
`ui-text-button--${this.size}`,
this.disabled ? 'ui-text-button--disabled' : '',
this.loading ? 'ui-text-button--loading' : '',
this.class
].filter(Boolean).join(' ');
}
handleClick(event: Event): void {
if (!this.disabled && !this.loading) {
this.clicked.emit(event);
}
}
}

View File

@@ -0,0 +1,279 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-accordion {
// Core Structure
display: flex;
flex-direction: column;
position: relative;
width: 100%;
box-sizing: border-box;
// Base styles
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
&--sm {
.ui-accordion__header {
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
font-size: $semantic-typography-font-size-sm;
}
.ui-accordion__content {
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
font-size: $semantic-typography-font-size-sm;
}
}
&--md {
.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;
}
}
&--lg {
.ui-accordion__header {
padding: $semantic-spacing-component-md $semantic-spacing-component-lg;
font-size: $semantic-typography-font-size-lg;
}
.ui-accordion__content {
padding: $semantic-spacing-component-md $semantic-spacing-component-lg;
font-size: $semantic-typography-font-size-lg;
}
}
// Variant styles
&--elevated {
.ui-accordion__item {
background: $semantic-color-surface-primary;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
border-radius: $semantic-border-radius-md;
box-shadow: $semantic-shadow-elevation-1;
margin-bottom: $semantic-spacing-component-xs;
&:last-child {
margin-bottom: 0;
}
}
}
&--outlined {
.ui-accordion__item {
background: $semantic-color-surface-primary;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
border-radius: $semantic-border-radius-md;
margin-bottom: $semantic-spacing-component-xs;
&:last-child {
margin-bottom: 0;
}
}
}
&--filled {
.ui-accordion__item {
background: $semantic-color-surface-secondary;
border: $semantic-border-width-1 solid $semantic-color-surface-secondary;
border-radius: $semantic-border-radius-md;
margin-bottom: $semantic-spacing-component-xs;
&:last-child {
margin-bottom: 0;
}
}
}
// State variants
&--disabled {
opacity: 0.38;
cursor: not-allowed;
pointer-events: none;
}
// Individual accordion item
&__item {
position: relative;
overflow: hidden;
transition: all $semantic-motion-duration-fast $semantic-easing-standard;
&--expanded {
.ui-accordion__header {
.ui-accordion__icon {
transform: rotate(180deg);
}
}
}
&--disabled {
opacity: 0.38;
cursor: not-allowed;
.ui-accordion__header {
cursor: not-allowed;
pointer-events: none;
}
}
}
// Header/trigger area
&__header {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
background: transparent;
border: none;
cursor: pointer;
color: $semantic-color-text-primary;
font-weight: $semantic-typography-font-weight-medium;
text-align: left;
transition: all $semantic-motion-duration-fast $semantic-easing-standard;
&:hover:not(:disabled) {
background: $semantic-color-surface-secondary;
}
&:focus-visible {
outline: 2px solid $semantic-color-primary;
outline-offset: 2px;
}
&:active:not(:disabled) {
background: $semantic-color-surface-elevated;
}
&:disabled {
cursor: not-allowed;
opacity: 0.38;
}
}
// Header content area
&__header-content {
flex: 1;
display: flex;
align-items: center;
gap: $semantic-spacing-component-sm;
}
// Icon container
&__icon {
display: flex;
align-items: center;
justify-content: center;
width: $semantic-sizing-icon-inline;
height: $semantic-sizing-icon-inline;
color: $semantic-color-text-secondary;
transition: transform $semantic-motion-duration-fast $semantic-easing-standard;
flex-shrink: 0;
}
// Content area
&__content {
overflow: hidden;
background: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
line-height: $semantic-typography-line-height-relaxed;
// Animation states
&--collapsed {
max-height: 0;
padding-top: 0;
padding-bottom: 0;
transition:
max-height $semantic-motion-duration-normal $semantic-easing-standard,
padding $semantic-motion-duration-normal $semantic-easing-standard;
}
&--expanded {
max-height: 1000px; // Large enough for most content
transition: max-height $semantic-motion-duration-normal $semantic-easing-standard;
}
&--expanding {
transition:
max-height $semantic-motion-duration-normal $semantic-easing-standard,
padding $semantic-motion-duration-normal $semantic-easing-standard;
}
}
// Content inner wrapper for proper padding animation
&__content-inner {
padding-top: $semantic-spacing-component-xs;
}
// Multiple expand mode styling
&--multiple {
.ui-accordion__item:not(:last-child) {
border-bottom: $semantic-border-width-1 solid $semantic-color-border-subtle;
}
}
// Dark mode support
:host-context(.dark-theme) & {
.ui-accordion__item {
background: $semantic-color-surface-elevated;
border-color: $semantic-color-border-secondary;
}
.ui-accordion__header {
color: $semantic-color-text-primary;
&:hover:not(:disabled) {
background: $semantic-color-surface-secondary;
}
}
.ui-accordion__content {
background: $semantic-color-surface-elevated;
color: $semantic-color-text-primary;
}
}
// Responsive design
@media (max-width: $semantic-breakpoint-md - 1) {
&--lg {
.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;
}
}
}
@media (max-width: $semantic-breakpoint-sm - 1) {
&--md,
&--lg {
.ui-accordion__header {
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
font-size: $semantic-typography-font-size-sm;
}
.ui-accordion__content {
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
font-size: $semantic-typography-font-size-sm;
}
}
}
}

View File

@@ -0,0 +1,370 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, ContentChildren, QueryList, AfterContentInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { Subject, takeUntil } from 'rxjs';
export type AccordionSize = 'sm' | 'md' | 'lg';
export type AccordionVariant = 'elevated' | 'outlined' | 'filled';
export interface AccordionItem {
id: string;
title: string;
content?: string;
disabled?: boolean;
expanded?: boolean;
}
export interface AccordionExpandEvent {
item: AccordionItem;
index: number;
expanded: boolean;
}
@Component({
selector: 'ui-accordion-item',
standalone: true,
imports: [CommonModule],
template: `
<div class="ui-accordion__item"
[class.ui-accordion__item--expanded]="expanded"
[class.ui-accordion__item--disabled]="disabled">
<button
type="button"
class="ui-accordion__header"
[disabled]="disabled"
[attr.aria-expanded]="expanded"
[attr.aria-controls]="contentId"
[attr.id]="headerId"
(click)="handleToggle()"
(keydown)="handleKeydown($event)">
<div class="ui-accordion__header-content">
<ng-content select="[slot='header']"></ng-content>
@if (!hasHeaderContent) {
<span>{{ title }}</span>
}
</div>
<div class="ui-accordion__icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M4.5 6l3.5 3.5L11.5 6"/>
</svg>
</div>
</button>
<div
class="ui-accordion__content"
[class.ui-accordion__content--expanded]="expanded"
[class.ui-accordion__content--collapsed]="!expanded"
[attr.id]="contentId"
[attr.aria-labelledby]="headerId"
role="region">
@if (expanded || !collapseContent) {
<div class="ui-accordion__content-inner">
<ng-content></ng-content>
@if (!hasContent && content) {
<p>{{ content }}</p>
}
</div>
}
</div>
</div>
`
})
export class AccordionItemComponent {
@Input() id!: string;
@Input() title!: string;
@Input() content?: string;
@Input() disabled = false;
@Input() expanded = false;
@Input() collapseContent = true; // Whether to remove content from DOM when collapsed
@Input() hasHeaderContent = false;
@Input() hasContent = false;
@Output() expandedChange = new EventEmitter<boolean>();
@Output() itemToggle = new EventEmitter<AccordionExpandEvent>();
get headerId(): string {
return `accordion-header-${this.id}`;
}
get contentId(): string {
return `accordion-content-${this.id}`;
}
handleToggle(): void {
if (!this.disabled) {
this.expanded = !this.expanded;
this.expandedChange.emit(this.expanded);
this.itemToggle.emit({
item: {
id: this.id,
title: this.title,
content: this.content,
disabled: this.disabled,
expanded: this.expanded
},
index: 0, // Will be set by parent accordion
expanded: this.expanded
});
}
}
handleKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.handleToggle();
}
}
}
@Component({
selector: 'ui-accordion',
standalone: true,
imports: [CommonModule, AccordionItemComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-accordion"
[class]="getComponentClasses()"
[attr.aria-multiselectable]="multiple"
role="group">
@if (items && items.length > 0) {
@for (item of items; track item.id; let i = $index) {
<ui-accordion-item
[id]="item.id"
[title]="item.title"
[content]="item.content"
[disabled]="item.disabled || disabled"
[expanded]="item.expanded || false"
[collapseContent]="collapseContent"
(itemToggle)="handleItemToggle($event, i)">
</ui-accordion-item>
}
} @else {
<ng-content></ng-content>
}
</div>
`,
styleUrl: './accordion.component.scss'
})
export class AccordionComponent implements AfterContentInit, OnDestroy {
@Input() size: AccordionSize = 'md';
@Input() variant: AccordionVariant = 'elevated';
@Input() disabled = false;
@Input() multiple = false; // Allow multiple items to be expanded simultaneously
@Input() collapseContent = true; // Whether to remove content from DOM when collapsed
@Input() items: AccordionItem[] = []; // Programmatic items
@Input() expandedItems: string[] = []; // IDs of initially expanded items
@Output() itemToggle = new EventEmitter<AccordionExpandEvent>();
@Output() expandedItemsChange = new EventEmitter<string[]>();
@ContentChildren(AccordionItemComponent) accordionItems!: QueryList<AccordionItemComponent>;
private destroy$ = new Subject<void>();
getComponentClasses(): string {
const classes = [
'ui-accordion',
`ui-accordion--${this.size}`,
`ui-accordion--${this.variant}`
];
if (this.disabled) {
classes.push('ui-accordion--disabled');
}
if (this.multiple) {
classes.push('ui-accordion--multiple');
}
return classes.join(' ');
}
ngAfterContentInit(): void {
// Set up keyboard navigation between accordion items
this.setupKeyboardNavigation();
// Initialize expanded states
this.initializeExpandedStates();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
private setupKeyboardNavigation(): void {
this.accordionItems.changes
.pipe(takeUntil(this.destroy$))
.subscribe(() => {
this.accordionItems.forEach((item, index) => {
// Update index for event emission
const originalToggle = item.handleToggle.bind(item);
item.handleToggle = () => {
originalToggle();
// Emit with correct index
const event = {
item: {
id: item.id,
title: item.title,
content: item.content,
disabled: item.disabled,
expanded: item.expanded
},
index,
expanded: item.expanded
};
this.handleItemToggle(event, index);
};
});
});
}
private initializeExpandedStates(): void {
if (this.items.length > 0) {
// Initialize programmatic items
this.items.forEach(item => {
if (this.expandedItems.includes(item.id)) {
item.expanded = true;
}
});
}
}
handleItemToggle(event: AccordionExpandEvent, index: number): void {
event.index = index;
if (!this.multiple && event.expanded) {
// Close other items if not in multiple mode
this.closeOtherItems(event.item.id);
}
// Update expanded items list
this.updateExpandedItems(event.item.id, event.expanded);
// Emit events
this.itemToggle.emit(event);
this.expandedItemsChange.emit([...this.expandedItems]);
}
private closeOtherItems(exceptId: string): void {
if (this.items.length > 0) {
// Close other programmatic items
this.items.forEach(item => {
if (item.id !== exceptId && item.expanded) {
item.expanded = false;
}
});
}
// Close other content-projected items
this.accordionItems.forEach(item => {
if (item.id !== exceptId && item.expanded) {
item.expanded = false;
item.expandedChange.emit(false);
}
});
}
private updateExpandedItems(itemId: string, expanded: boolean): void {
if (expanded) {
if (!this.expandedItems.includes(itemId)) {
this.expandedItems.push(itemId);
}
} else {
const index = this.expandedItems.indexOf(itemId);
if (index > -1) {
this.expandedItems.splice(index, 1);
}
}
}
// Public methods for programmatic control
expandItem(itemId: string): void {
const item = this.items.find(i => i.id === itemId);
const accordionItem = this.accordionItems.find(i => i.id === itemId);
if (item && !item.disabled) {
if (!this.multiple) {
this.closeAllItems();
}
item.expanded = true;
this.updateExpandedItems(itemId, true);
}
if (accordionItem && !accordionItem.disabled) {
if (!this.multiple) {
this.closeAllItems();
}
accordionItem.expanded = true;
accordionItem.expandedChange.emit(true);
this.updateExpandedItems(itemId, true);
}
}
collapseItem(itemId: string): void {
const item = this.items.find(i => i.id === itemId);
const accordionItem = this.accordionItems.find(i => i.id === itemId);
if (item) {
item.expanded = false;
this.updateExpandedItems(itemId, false);
}
if (accordionItem) {
accordionItem.expanded = false;
accordionItem.expandedChange.emit(false);
this.updateExpandedItems(itemId, false);
}
}
expandAll(): void {
if (this.multiple) {
this.items.forEach(item => {
if (!item.disabled) {
item.expanded = true;
this.updateExpandedItems(item.id, true);
}
});
this.accordionItems.forEach(item => {
if (!item.disabled) {
item.expanded = true;
item.expandedChange.emit(true);
this.updateExpandedItems(item.id, true);
}
});
}
}
collapseAll(): void {
this.items.forEach(item => {
item.expanded = false;
this.updateExpandedItems(item.id, false);
});
this.accordionItems.forEach(item => {
item.expanded = false;
item.expandedChange.emit(false);
this.updateExpandedItems(item.id, false);
});
}
private closeAllItems(): void {
this.items.forEach(item => {
item.expanded = false;
});
this.accordionItems.forEach(item => {
item.expanded = false;
item.expandedChange.emit(false);
});
this.expandedItems = [];
}
}

View File

@@ -0,0 +1,2 @@
export { AccordionComponent, AccordionItemComponent } from './accordion.component';
export type { AccordionSize, AccordionVariant, AccordionItem, AccordionExpandEvent } from './accordion.component';

View File

@@ -0,0 +1,244 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.skyui-avatar {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
border-radius: 50%;
background-color: $semantic-color-surface-secondary;
color: $semantic-color-text-secondary;
box-sizing: border-box;
flex-shrink: 0;
user-select: none;
cursor: pointer;
// Size variants based on semantic sizing tokens
&--size-xs {
width: 24px;
height: 24px;
.skyui-avatar__initials {
font-size: calc(24px * 0.4);
font-weight: 500;
}
.skyui-avatar__icon {
font-size: calc(24px * 0.6);
}
}
&--size-sm {
width: 32px;
height: 32px;
.skyui-avatar__initials {
font-size: calc(32px * 0.4);
font-weight: 500;
}
.skyui-avatar__icon {
font-size: calc(32px * 0.6);
}
}
&--size-md {
width: 40px;
height: 40px;
.skyui-avatar__initials {
font-size: calc(40px * 0.4);
font-weight: 500;
}
.skyui-avatar__icon {
font-size: calc(40px * 0.6);
}
}
&--size-lg {
width: 48px;
height: 48px;
.skyui-avatar__initials {
font-size: calc(48px * 0.4);
font-weight: 600;
}
.skyui-avatar__icon {
font-size: calc(48px * 0.6);
}
}
&--size-xl {
width: 64px;
height: 64px;
.skyui-avatar__initials {
font-size: calc(64px * 0.35);
font-weight: 600;
}
.skyui-avatar__icon {
font-size: calc(64px * 0.5);
}
}
&--size-xxl {
width: 80px;
height: 80px;
.skyui-avatar__initials {
font-size: calc(80px * 0.35);
font-weight: 600;
}
.skyui-avatar__icon {
font-size: calc(80px * 0.5);
}
}
// Loading state
&--loading {
background-color: $semantic-color-surface-disabled;
.skyui-avatar__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
}
.loading-spinner {
width: 50%;
height: 50%;
border: 2px solid $semantic-color-border-subtle;
border-top: 2px solid $semantic-color-brand-primary;
border-radius: 50%;
animation: spin 1s linear infinite;
}
}
// Avatar image
&__image {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 50%;
overflow: hidden;
}
// Fallback container
&__fallback {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background-color: $semantic-color-container-primary;
color: $semantic-color-on-container-primary;
border-radius: 50%;
overflow: hidden;
}
// Initials text
&__initials {
line-height: 1;
text-align: center;
letter-spacing: -0.02em;
}
// Icon
&__icon {
display: flex;
align-items: center;
justify-content: center;
}
// Status indicator
&__status {
position: absolute;
bottom: 0;
right: 0;
width: 25%;
height: 25%;
min-width: 8px;
min-height: 8px;
border-radius: 50%;
border: 2px solid $semantic-color-surface-primary;
box-sizing: border-box;
&--online {
background-color: $semantic-color-success;
}
&--offline {
background-color: $semantic-color-text-tertiary;
}
&--away {
background-color: $semantic-color-warning;
}
&--busy {
background-color: $semantic-color-danger;
}
}
// Badge indicator positioning
&__badge {
position: absolute;
top: -4px;
right: -4px;
}
}
// Animation for loading spinner
@keyframes spin {
0% {
transform: rotate(0deg);
}
100% {
transform: rotate(360deg);
}
}
// Focus and hover states for interactive avatars
.skyui-avatar {
&[role="button"]:focus-visible,
&[tabindex]:focus-visible {
outline: 2px solid $semantic-color-brand-primary;
outline-offset: 2px;
}
&[role="button"]:hover,
&[tabindex]:hover {
transform: scale(1.05);
transition: transform $semantic-duration-fast $semantic-easing-standard;
}
}
// High contrast mode support
@media (prefers-contrast: high) {
.skyui-avatar {
border: $semantic-border-width-1 solid $semantic-color-border-primary;
&__status {
border-width: $semantic-border-width-3;
}
}
}
// Reduced motion support
@media (prefers-reduced-motion: reduce) {
.skyui-avatar {
&[role="button"]:hover,
&[tabindex]:hover {
transition: none;
}
.loading-spinner {
animation: none;
}
}
}

View File

@@ -0,0 +1,108 @@
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { faUser } from '@fortawesome/free-solid-svg-icons';
import { BadgeComponent } from '../badge/badge.component';
export type AvatarSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl' | 'xxl';
@Component({
selector: 'ui-avatar',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, FontAwesomeModule, BadgeComponent],
template: `
<div class="skyui-avatar"
[class]="'skyui-avatar--size-' + size"
[class.skyui-avatar--loading]="loading"
[attr.aria-label]="ariaLabel || (name ? name + ' avatar' : 'User avatar')">
@if (loading) {
<div class="skyui-avatar__loading">
<div class="loading-spinner" aria-hidden="true"></div>
</div>
} @else {
@if (imageUrl && !imageError) {
<img
[src]="imageUrl"
[alt]="altText || (name ? name + ' avatar' : 'User avatar')"
class="skyui-avatar__image"
(error)="onImageError()"
(load)="onImageLoad()">
} @else {
<div class="skyui-avatar__fallback">
@if (computedInitials) {
<span class="skyui-avatar__initials" aria-hidden="true">{{ computedInitials }}</span>
} @else {
<fa-icon
[icon]="faUser"
class="skyui-avatar__icon"
aria-hidden="true">
</fa-icon>
}
</div>
}
}
@if (status) {
<div class="skyui-avatar__status"
[class]="'skyui-avatar__status--' + status"
[attr.aria-label]="statusLabel || status + ' status'">
</div>
}
@if (badge !== undefined && badge !== null) {
<ui-badge
class="skyui-avatar__badge"
variant="danger"
size="xs"
[ariaLabel]="badgeLabel || 'Badge: ' + badge">
{{ badge }}
</ui-badge>
}
</div>
`,
styleUrl: './avatar.component.scss'
})
export class AvatarComponent {
@Input() imageUrl?: string;
@Input() name?: string;
@Input() initials?: string;
@Input() size: AvatarSize = 'md';
@Input() altText?: string;
@Input() ariaLabel?: string;
@Input() loading: boolean = false;
@Input() status?: 'online' | 'offline' | 'away' | 'busy';
@Input() statusLabel?: string;
@Input() badge?: string | number;
@Input() badgeLabel?: string;
faUser = faUser;
imageError = false;
onImageError(): void {
this.imageError = true;
}
onImageLoad(): void {
this.imageError = false;
}
// Auto-generate initials from name if not provided
get computedInitials(): string {
if (this.initials) {
return this.initials.slice(0, 2).toUpperCase();
}
if (this.name) {
const nameParts = this.name.trim().split(/\s+/);
if (nameParts.length >= 2) {
return (nameParts[0][0] + nameParts[nameParts.length - 1][0]).toUpperCase();
} else if (nameParts[0]) {
return nameParts[0].slice(0, 2).toUpperCase();
}
}
return '';
}
}

View File

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

View File

@@ -0,0 +1,176 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
/**
* ==========================================================================
* BADGE COMPONENT STYLES
* ==========================================================================
* Material Design 3 inspired badge component with design token integration.
* Supports multiple variants, sizes, shapes, and dot mode for notifications.
* ==========================================================================
*/
// Tokens available globally via main application styles
.ui-badge {
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: $semantic-typography-font-weight-medium;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: middle;
user-select: none;
box-sizing: border-box;
transition: all $semantic-duration-short $semantic-easing-standard;
background-color: aqua;
// Default size (md)
min-width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
padding: 0 $semantic-spacing-component-xs;
font-size: $semantic-typography-font-size-xs;
border-radius: $semantic-border-radius-full;
// Default variant styling
background-color: $semantic-color-surface-secondary;
color: $semantic-color-text-primary;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
// Size variants
&[data-size="xs"] {
min-width: $semantic-typography-icon-small-size;
height: $semantic-typography-icon-small-size;
padding: 0 calc($semantic-spacing-component-xs / 2);
font-size: $semantic-typography-font-size-xs;
}
&[data-size="sm"] {
min-width: $semantic-sizing-icon-button;
height: $semantic-sizing-icon-button;
padding: 0 $semantic-spacing-component-xs;
font-size: $semantic-typography-font-size-xs;
}
&[data-size="md"] {
min-width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
padding: 0 $semantic-spacing-component-xs;
font-size: $semantic-typography-font-size-xs;
}
&[data-size="lg"] {
min-width: $semantic-sizing-icon-header;
height: $semantic-sizing-icon-header;
padding: 0 $semantic-spacing-component-sm;
font-size: $semantic-typography-font-size-sm;
}
// Shape variants
&[data-shape="pill"] {
border-radius: $semantic-border-radius-full;
}
&[data-shape="rounded"] {
border-radius: $semantic-border-radius-md;
}
&[data-shape="square"] {
border-radius: $semantic-border-radius-sm;
}
// Dot mode - small notification dot
&[data-dot="true"] {
min-width: 0;
width: 8px;
height: 8px;
padding: 0;
border-radius: 50%;
border: 2px solid $semantic-color-surface-primary;
&[data-size="xs"] {
width: 6px;
height: 6px;
}
&[data-size="sm"] {
width: 8px;
height: 8px;
}
&[data-size="md"] {
width: 10px;
height: 10px;
}
&[data-size="lg"] {
width: 12px;
height: 12px;
}
}
// Color variants
&[data-variant="default"] {
background-color: $semantic-color-surface-secondary;
color: $semantic-color-text-primary;
border-color: $semantic-color-border-subtle;
}
&[data-variant="primary"] {
background-color: $semantic-color-brand-primary;
color: $semantic-color-text-inverse;
border-color: $semantic-color-brand-primary;
}
&[data-variant="secondary"] {
background-color: $semantic-color-brand-secondary;
color: $semantic-color-text-inverse;
border-color: $semantic-color-brand-secondary;
}
&[data-variant="success"] {
background-color: $semantic-color-success;
color: $semantic-color-text-inverse;
border-color: $semantic-color-success;
}
&[data-variant="warning"] {
background-color: $semantic-color-warning;
color: $semantic-color-text-inverse;
border-color: $semantic-color-warning;
}
&[data-variant="danger"] {
background-color: $semantic-color-danger;
color: $semantic-color-text-inverse;
border-color: $semantic-color-danger;
}
&[data-variant="info"] {
background-color: $semantic-color-info;
color: $semantic-color-text-inverse;
border-color: $semantic-color-info;
}
// Hover states for interactive badges
&:hover {
transform: translateY(-1px);
box-shadow: $semantic-shadow-elevation-1;
}
// Focus states for accessibility
&:focus {
outline: 2px solid $semantic-color-focus-ring;
outline-offset: 2px;
}
// Responsive adjustments
@media (max-width: calc($semantic-breakpoint-sm - 1px)) {
&[data-size="lg"] {
min-width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
padding: 0 $semantic-spacing-component-xs;
font-size: $semantic-typography-font-size-xs;
}
}
}

View File

@@ -0,0 +1,37 @@
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
export type BadgeVariant = 'default' | 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info';
export type BadgeSize = 'xs' | 'sm' | 'md' | 'lg';
export type BadgeShape = 'pill' | 'rounded' | 'square';
@Component({
selector: 'ui-badge',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<span
class="ui-badge"
[attr.data-variant]="variant"
[attr.data-size]="size"
[attr.data-shape]="shape"
[attr.data-dot]="isDot"
[attr.aria-label]="ariaLabel"
[title]="title"
>
@if (!isDot) {
<ng-content></ng-content>
}
</span>
`,
styleUrls: ['./badge.component.scss']
})
export class BadgeComponent {
@Input() variant: BadgeVariant = 'default';
@Input() size: BadgeSize = 'md';
@Input() shape: BadgeShape = 'pill';
@Input() isDot: boolean = false;
@Input() ariaLabel?: string;
@Input() title?: string;
}

View File

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

View File

@@ -0,0 +1,400 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
// Tokens available globally via main application styles
.ui-card {
position: relative;
width: 100%;
box-sizing: border-box;
margin-right: $semantic-spacing-grid-gap-xs;
margin-bottom: $semantic-spacing-grid-gap-xs;
overflow: hidden;
display: flex;
flex-direction: column;
// Base styles
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
transition: all $semantic-duration-fast $semantic-easing-standard;
// Size variants - following 8px grid increments
&--sm {
min-height: $semantic-sizing-card-height-sm;
.card-content-layer {
padding: $semantic-spacing-component-padding-sm;
}
.card-header {
padding-bottom: $semantic-spacing-component-sm;
}
.card-footer {
padding-top: $semantic-spacing-component-sm;
}
}
&--md {
min-height: $semantic-sizing-card-height-md;
.card-content-layer {
padding: $semantic-spacing-component-padding-lg;
}
.card-header {
padding-bottom: $semantic-spacing-component-md;
}
.card-footer {
padding-top: $semantic-spacing-component-md;
}
}
&--lg {
min-height: $semantic-sizing-card-height-lg;
.card-content-layer {
padding: $semantic-spacing-component-padding-xl;
}
.card-header {
padding-bottom: $semantic-spacing-component-lg;
}
.card-footer {
padding-top: $semantic-spacing-component-lg;
}
}
// Elevation variants - Material Design shadow levels
&--elevation-none {
box-shadow: none;
}
&--elevation-sm {
box-shadow: $semantic-shadow-elevation-1;
}
&--elevation-md {
box-shadow: $semantic-shadow-elevation-2;
}
&--elevation-lg {
box-shadow: $semantic-shadow-elevation-3;
}
&--elevation-xl {
box-shadow: $semantic-shadow-elevation-4;
}
// Radius variants - consistent scale
&--radius-none {
border-radius: 0;
}
&--radius-sm {
border-radius: $semantic-border-radius-sm;
}
&--radius-md {
border-radius: $semantic-border-radius-md;
}
&--radius-lg {
border-radius: $semantic-border-radius-lg;
}
&--radius-full {
border-radius: $semantic-border-radius-full;
aspect-ratio: 1;
}
// Variant styles - Material Design 3 inspired colors
&--elevated {
background-color: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
border: none;
&:hover:not(.ui-card--disabled) {
transform: translateY(-2px);
box-shadow: $semantic-shadow-card-hover;
}
}
&--filled {
background-color: $semantic-color-surface-secondary;
color: $semantic-color-text-primary;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
&:hover:not(.ui-card--disabled) {
background-color: $semantic-color-surface-elevated;
}
}
&--outlined {
background-color: transparent;
color: $semantic-color-text-primary;
border: $semantic-border-width-1 solid $semantic-color-border-secondary;
&:hover:not(.ui-card--disabled) {
border-color: $semantic-color-border-focus;
background-color: $semantic-color-surface-elevated;
}
}
// Clickable state
&--clickable {
cursor: pointer;
user-select: none;
&:active:not(.ui-card--disabled) {
transform: scale(0.98); // Subtle press effect
}
&:focus-visible {
outline: 2px solid $semantic-color-border-focus;
outline-offset: 2px;
}
}
// Disabled state
&--disabled {
opacity: 0.38; // Material Design disabled opacity
cursor: not-allowed;
pointer-events: none;
}
// Glass effect using CSS variables and semantic approach
&--glass {
position: relative;
isolation: isolate;
backdrop-filter: blur(var(--glass-blur-md, 8px));
-webkit-backdrop-filter: blur(var(--glass-blur-md, 8px));
border: 1px solid var(--glass-border-color, rgba(255, 255, 255, 0.2));
transition: all $semantic-duration-short $semantic-easing-standard;
.card-content-layer {
position: relative;
z-index: 2;
}
// Glass variant styles using CSS variables - fixed syntax
&.ui-card--glass-translucent {
background: rgba(var(--glass-background-base, 255, 255, 255), var(--glass-opacity-translucent, 0.1));
backdrop-filter: blur(var(--glass-blur-sm, 4px));
-webkit-backdrop-filter: blur(var(--glass-blur-sm, 4px));
}
&.ui-card--glass-light {
background: rgba(var(--glass-background-base, 255, 255, 255), var(--glass-opacity-light, 0.3));
backdrop-filter: blur(var(--glass-blur-md, 8px));
-webkit-backdrop-filter: blur(var(--glass-blur-md, 8px));
}
&.ui-card--glass-medium {
background: rgba(var(--glass-background-base, 255, 255, 255), var(--glass-opacity-medium, 0.5));
backdrop-filter: blur(var(--glass-blur-md, 8px));
-webkit-backdrop-filter: blur(var(--glass-blur-md, 8px));
}
&.ui-card--glass-heavy {
background: rgba(var(--glass-background-base, 255, 255, 255), var(--glass-opacity-heavy, 0.7));
backdrop-filter: blur(var(--glass-blur-lg, 16px));
-webkit-backdrop-filter: blur(var(--glass-blur-lg, 16px));
}
&.ui-card--glass-frosted {
background: rgba(var(--glass-background-base, 255, 255, 255), var(--glass-opacity-frosted, 0.85));
backdrop-filter: blur(var(--glass-blur-xl, 24px));
-webkit-backdrop-filter: blur(var(--glass-blur-xl, 24px));
}
// Interactive glass states
&.ui-card--clickable:hover:not(.ui-card--disabled) {
transform: translateY(-2px);
box-shadow: $semantic-shadow-elevation-3;
}
&.ui-card--clickable:active:not(.ui-card--disabled) {
transform: scale(0.98);
}
// Disabled state for glass cards
&.ui-card--disabled {
backdrop-filter: blur(var(--glass-blur-xs, 2px));
-webkit-backdrop-filter: blur(var(--glass-blur-xs, 2px));
opacity: 0.6;
}
}
// With background layer
&--with-background {
.card-background-layer {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
box-sizing: border-box;
transform: translateZ(0);
z-index: 0;
}
.card-content-layer {
position: relative;
z-index: 1;
background: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
}
}
}
// Card content structure
.card-content-layer {
display: flex;
flex-direction: column;
height: 100%;
flex: 1;
color: inherit;
}
.card-header {
border-bottom: $semantic-border-width-1 solid $semantic-color-border-secondary;
h1, h2, h3, h4, h5, h6 {
margin: 0;
color: $semantic-color-text-primary;
}
p {
margin: 0;
color: $semantic-color-text-secondary;
}
}
.card-body {
flex: 1;
color: inherit;
// Typography reset
> *:first-child {
margin-top: 0;
}
> *:last-child {
margin-bottom: 0;
}
p {
color: $semantic-color-text-primary;
}
}
.card-footer {
border-top: $semantic-border-width-1 solid $semantic-color-border-secondary;
display: flex;
justify-content: flex-end;
align-items: center;
gap: $semantic-spacing-component-sm;
}
// Glass overlay for enhanced glass effect
.card-glass-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(145deg,
rgba(255, 255, 255, 0.1) 0%,
rgba(255, 255, 255, 0.05) 50%,
rgba(255, 255, 255, 0.1) 100%);
pointer-events: none;
z-index: 1;
border-radius: inherit;
}
// Ripple effect for clickable cards
.card-ripple-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
overflow: hidden;
pointer-events: none;
z-index: 3;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 0;
height: 0;
border-radius: 50%;
background: rgba(120, 53, 255, 0.2); // Brand color ripple
transform: translate(-50%, -50%) scale(0);
opacity: 0;
transition: transform 0.3s ease, opacity 0.3s ease;
}
.ui-card--clickable:active & {
&::after {
transform: translate(-50%, -50%) scale(2);
opacity: 1;
transition: none;
}
}
}
// Hover effects based on elevation
.ui-card:hover:not(.ui-card--disabled) {
&.ui-card--elevation-sm {
box-shadow: $semantic-shadow-elevation-2;
}
&.ui-card--elevation-md {
box-shadow: $semantic-shadow-elevation-3;
}
&.ui-card--elevation-lg {
box-shadow: $semantic-shadow-elevation-4;
}
}
// Dark mode support - Remove problematic dark mode overrides
// Semantic tokens should handle dark mode automatically
// Responsive adaptations
@media (max-width: ($semantic-breakpoint-md - 1)) {
.ui-card {
margin-right: 0;
&--lg {
.card-content-layer {
padding: $semantic-spacing-component-padding-lg;
}
}
.card-footer {
flex-direction: column;
gap: $semantic-spacing-component-md;
}
}
}
// Accessibility
@media (prefers-reduced-motion: reduce) {
.ui-card {
transition: none;
&:hover,
&:active {
transform: none;
}
}
.card-ripple-overlay::after {
transition: none;
}
}

View File

@@ -0,0 +1,99 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
export type CardVariant = 'elevated' | 'filled' | 'outlined';
export type CardSize = 'sm' | 'md' | 'lg';
export type CardElevation = 'none' | 'sm' | 'md' | 'lg' | 'xl';
export type CardRadius = 'none' | 'sm' | 'md' | 'lg' | 'full';
export type GlassVariant = 'translucent' | 'light' | 'medium' | 'heavy' | 'frosted';
@Component({
selector: 'ui-card',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
[class]="cardClasses"
[style.backgroundImage]="backgroundImage"
[attr.tabindex]="clickable && !disabled ? '0' : null"
[attr.role]="clickable ? 'button' : null"
[attr.aria-disabled]="disabled"
(click)="handleClick($event)"
(keydown.enter)="handleClick($event)"
(keydown.space)="handleClick($event)"
>
@if (hasBackgroundLayer) {
<div class="card-background-layer">
<ng-content select="[slot=background]"></ng-content>
</div>
}
<div class="card-content-layer">
@if (hasHeader) {
<div class="card-header">
<ng-content select="[slot=header]"></ng-content>
</div>
}
<div class="card-body">
<ng-content></ng-content>
</div>
@if (hasFooter) {
<div class="card-footer">
<ng-content select="[slot=footer]"></ng-content>
</div>
}
</div>
@if (glass) {
<div class="card-glass-overlay"></div>
}
@if (clickable && !disabled) {
<div class="card-ripple-overlay"></div>
}
</div>
`,
styleUrl: './card.component.scss'
})
export class CardComponent {
@Input() variant: CardVariant = 'elevated';
@Input() size: CardSize = 'md';
@Input() elevation: CardElevation = 'sm';
@Input() radius: CardRadius = 'md';
@Input() disabled: boolean = false;
@Input() clickable: boolean = false;
@Input() glass: boolean = false;
@Input() glassVariant: GlassVariant = 'medium';
@Input() backgroundImage?: string;
@Input() hasBackgroundLayer: boolean = false;
@Input() hasHeader: boolean = false;
@Input() hasFooter: boolean = false;
@Input() class: string = '';
@Output() cardClick = new EventEmitter<Event>();
get cardClasses(): string {
return [
'ui-card',
`ui-card--${this.variant}`,
`ui-card--${this.size}`,
`ui-card--elevation-${this.elevation}`,
`ui-card--radius-${this.radius}`,
this.disabled ? 'ui-card--disabled' : '',
this.clickable ? 'ui-card--clickable' : '',
this.glass ? 'ui-card--glass' : '',
this.glass ? `ui-card--glass-${this.glassVariant}` : '',
this.hasBackgroundLayer ? 'ui-card--with-background' : '',
this.class
].filter(Boolean).join(' ');
}
handleClick(event: Event): void {
if (this.clickable && !this.disabled) {
this.cardClick.emit(event);
}
}
}

View File

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

View File

@@ -0,0 +1,375 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
@mixin font-properties($font-map) {
@if type-of($font-map) == 'map' {
font-family: map-get($font-map, 'font-family');
font-size: map-get($font-map, 'font-size');
line-height: map-get($font-map, 'line-height');
font-weight: map-get($font-map, 'font-weight');
letter-spacing: map-get($font-map, 'letter-spacing');
} @else {
font: $font-map;
}
}
.ui-carousel {
position: relative;
width: 100%;
overflow: hidden;
border-radius: 12px;
background: $semantic-color-surface-container;
&__container {
position: relative;
width: 100%;
height: 400px; // Default height
overflow: hidden;
}
&__track {
display: flex;
width: 100%;
height: 100%;
transition: transform $semantic-duration-medium $semantic-easing-standard;
}
&__slide {
flex: 0 0 100%;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
opacity: 1;
transform: scale(1);
transition: opacity $semantic-duration-medium $semantic-easing-standard,
transform $semantic-duration-medium $semantic-easing-standard;
&--active {
opacity: 1;
transform: scale(1);
}
.ui-carousel__image-container,
.ui-carousel__card,
.ui-carousel__content-slide {
width: 100%;
height: 100%;
}
}
&__image-container {
position: relative;
width: 100%;
height: 100%;
.ui-carousel__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.ui-carousel__image-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
padding: $semantic-spacing-component-lg;
color: white;
.ui-carousel__title {
@include font-properties($semantic-typography-heading-h3);
margin-bottom: $semantic-spacing-component-sm;
}
.ui-carousel__subtitle {
@include font-properties($semantic-typography-body-medium);
opacity: 0.9;
}
}
}
&__card {
background: $semantic-color-surface-container;
border-radius: 8px;
overflow: hidden;
box-shadow: $semantic-shadow-elevation-2;
.ui-carousel__card-image {
width: 100%;
height: 60%;
object-fit: cover;
}
.ui-carousel__card-content {
padding: $semantic-spacing-component-lg;
height: 40%;
display: flex;
flex-direction: column;
justify-content: center;
.ui-carousel__title {
@include font-properties($semantic-typography-heading-h4);
margin-bottom: $semantic-spacing-component-sm;
}
.ui-carousel__subtitle {
@include font-properties($semantic-typography-body-medium);
color: $semantic-color-text-secondary;
margin-bottom: $semantic-spacing-component-sm;
}
.ui-carousel__content {
@include font-properties($semantic-typography-body-small);
color: $semantic-color-text-secondary;
}
}
}
&__content-slide {
padding: $semantic-spacing-component-lg;
text-align: center;
color: $semantic-color-text-primary;
display: flex;
flex-direction: column;
justify-content: center;
.ui-carousel__title {
@include font-properties($semantic-typography-heading-h3);
margin-bottom: $semantic-spacing-component-md;
}
.ui-carousel__subtitle {
@include font-properties($semantic-typography-body-medium);
color: $semantic-color-text-secondary;
margin-bottom: $semantic-spacing-component-md;
}
.ui-carousel__content {
@include font-properties($semantic-typography-body-medium);
color: $semantic-color-text-secondary;
}
}
&__nav {
position: absolute;
top: 50%;
transform: translateY(-50%);
z-index: 2;
background: rgba(255, 255, 255, 0.9);
border: none;
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: $semantic-shadow-elevation-3;
transition: all $semantic-motion-duration-fast $semantic-easing-standard;
&:hover {
background: white;
transform: translateY(-50%) scale(1.1);
}
&:focus {
outline: 2px solid $semantic-color-primary;
outline-offset: 2px;
}
&:disabled {
opacity: 0.5;
cursor: not-allowed;
transform: translateY(-50%);
}
&--prev {
left: $semantic-spacing-component-md;
}
&--next {
right: $semantic-spacing-component-md;
}
.ui-carousel__nav-icon {
width: 20px;
height: 20px;
color: $semantic-color-text-primary;
}
}
&__indicators {
position: absolute;
bottom: $semantic-spacing-component-md;
left: 50%;
transform: translateX(-50%);
display: flex;
gap: $semantic-spacing-component-xs;
z-index: 2;
&--dots .ui-carousel__indicator {
width: 12px;
height: 12px;
border-radius: 50%;
}
&--bars .ui-carousel__indicator {
width: 24px;
height: 4px;
border-radius: 2px;
}
&--thumbnails .ui-carousel__indicator {
width: 48px;
height: 32px;
border-radius: 4px;
overflow: hidden;
}
}
&__indicator {
border: none;
background: rgba(255, 255, 255, 0.5);
cursor: pointer;
transition: all $semantic-duration-medium $semantic-easing-standard;
&--active {
background: $semantic-color-primary;
transform: scale(1.2);
}
&:hover {
background: rgba(255, 255, 255, 0.8);
}
&:focus {
outline: 2px solid $semantic-color-primary;
outline-offset: 2px;
}
.ui-carousel__indicator-dot,
.ui-carousel__indicator-bar {
display: block;
width: 100%;
height: 100%;
border-radius: inherit;
}
.ui-carousel__indicator-thumbnail {
width: 100%;
height: 100%;
object-fit: cover;
}
}
// Size variants
&--sm {
.ui-carousel__container {
height: 250px;
}
}
&--md {
.ui-carousel__container {
height: 400px;
}
}
&--lg {
.ui-carousel__container {
height: 600px;
}
}
// Transition effects
&--fade {
.ui-carousel__track {
// For fade transition, we need to position slides absolutely
position: relative;
}
.ui-carousel__slide {
position: absolute;
top: 0;
left: 0;
right: 0;
opacity: 0;
transition: opacity $semantic-duration-medium $semantic-easing-standard;
flex: none; // Override the flex property
&--active {
opacity: 1;
z-index: 1;
}
}
}
&--scale {
.ui-carousel__track {
position: relative;
}
.ui-carousel__slide {
position: absolute;
top: 0;
left: 0;
right: 0;
transform: scale(0.8);
opacity: 0.3;
transition: transform $semantic-duration-medium $semantic-easing-standard,
opacity $semantic-duration-medium $semantic-easing-standard;
flex: none; // Override the flex property
&--active {
transform: scale(1);
opacity: 1;
z-index: 1;
}
}
}
&--slide {
// Default slide behavior - already handled by track transform
.ui-carousel__track {
transition: transform $semantic-duration-medium $semantic-easing-standard;
}
}
// Disabled state
&--disabled {
opacity: 0.6;
pointer-events: none;
}
// Responsive design
@media (max-width: 768px) {
&__container {
height: 300px;
}
&--sm .ui-carousel__container {
height: 200px;
}
&--lg .ui-carousel__container {
height: 400px;
}
&__nav {
width: 40px;
height: 40px;
.ui-carousel__nav-icon {
width: 16px;
height: 16px;
}
}
&__card .ui-carousel__card-content,
&__content-slide {
padding: $semantic-spacing-component-md;
}
}
}

View File

@@ -0,0 +1,277 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
export type CarouselSize = 'sm' | 'md' | 'lg';
export type CarouselVariant = 'card' | 'image' | 'content';
export type CarouselIndicatorStyle = 'dots' | 'bars' | 'thumbnails' | 'none';
export type CarouselTransition = 'slide' | 'fade' | 'scale';
export interface CarouselItem {
id: string | number;
imageUrl?: string;
title?: string;
subtitle?: string;
content?: string;
thumbnail?: string;
}
@Component({
selector: 'ui-carousel',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-carousel"
[class]="getComponentClasses()"
[attr.aria-roledescription]="'carousel'"
[attr.aria-label]="ariaLabel"
role="region">
<!-- Navigation buttons -->
@if (showNavigation && items.length > 1) {
<button
class="ui-carousel__nav ui-carousel__nav--prev"
[disabled]="disabled || (currentIndex() === 0 && !loop)"
(click)="goToPrevious()"
[attr.aria-label]="'Previous slide'"
type="button">
<svg class="ui-carousel__nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="15,18 9,12 15,6"></polyline>
</svg>
</button>
<button
class="ui-carousel__nav ui-carousel__nav--next"
[disabled]="disabled || (currentIndex() === items.length - 1 && !loop)"
(click)="goToNext()"
[attr.aria-label]="'Next slide'"
type="button">
<svg class="ui-carousel__nav-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<polyline points="9,18 15,12 9,6"></polyline>
</svg>
</button>
}
<!-- Carousel container -->
<div class="ui-carousel__container">
<div
class="ui-carousel__track"
[style.transform]="transition === 'slide' ? 'translateX(-' + (currentIndex() * 100) + '%)' : 'translateX(0)'">
@for (item of items; track item.id; let i = $index) {
<div
class="ui-carousel__slide"
[class.ui-carousel__slide--active]="i === currentIndex()"
[attr.aria-hidden]="i === currentIndex() ? 'false' : 'true'"
[attr.aria-label]="'Slide ' + (i + 1) + ' of ' + items.length">
<!-- Content based on variant -->
@switch (variant) {
@case ('image') {
@if (item.imageUrl) {
<div class="ui-carousel__image-container">
<img
[src]="item.imageUrl"
[alt]="item.title || 'Carousel image'"
class="ui-carousel__image"
loading="lazy">
@if (item.title || item.subtitle) {
<div class="ui-carousel__image-overlay">
@if (item.title) {
<h3 class="ui-carousel__title">{{ item.title }}</h3>
}
@if (item.subtitle) {
<p class="ui-carousel__subtitle">{{ item.subtitle }}</p>
}
</div>
}
</div>
}
}
@case ('card') {
<div class="ui-carousel__card">
@if (item.imageUrl) {
<img
[src]="item.imageUrl"
[alt]="item.title || 'Card image'"
class="ui-carousel__card-image"
loading="lazy">
}
<div class="ui-carousel__card-content">
@if (item.title) {
<h3 class="ui-carousel__title">{{ item.title }}</h3>
}
@if (item.subtitle) {
<p class="ui-carousel__subtitle">{{ item.subtitle }}</p>
}
@if (item.content) {
<p class="ui-carousel__content">{{ item.content }}</p>
}
</div>
</div>
}
@case ('content') {
<div class="ui-carousel__content-slide">
@if (item.title) {
<h3 class="ui-carousel__title">{{ item.title }}</h3>
}
@if (item.subtitle) {
<p class="ui-carousel__subtitle">{{ item.subtitle }}</p>
}
@if (item.content) {
<div class="ui-carousel__content" [innerHTML]="item.content"></div>
}
</div>
}
}
</div>
}
</div>
</div>
<!-- Indicators -->
@if (showIndicators && indicatorStyle !== 'none' && items.length > 1) {
<div class="ui-carousel__indicators" [class]="getIndicatorClasses()">
@for (item of items; track item.id; let i = $index) {
<button
class="ui-carousel__indicator"
[class.ui-carousel__indicator--active]="i === currentIndex()"
[disabled]="disabled"
(click)="goToSlide(i)"
[attr.aria-label]="'Go to slide ' + (i + 1)"
type="button">
@switch (indicatorStyle) {
@case ('thumbnails') {
@if (item.thumbnail || item.imageUrl) {
<img
[src]="item.thumbnail || item.imageUrl"
[alt]="item.title || 'Slide thumbnail'"
class="ui-carousel__indicator-thumbnail">
}
}
@case ('dots') {
<span class="ui-carousel__indicator-dot"></span>
}
@case ('bars') {
<span class="ui-carousel__indicator-bar"></span>
}
}
</button>
}
</div>
}
</div>
`,
styleUrl: './carousel.component.scss'
})
export class CarouselComponent {
@Input() items: CarouselItem[] = [];
@Input() size: CarouselSize = 'md';
@Input() variant: CarouselVariant = 'image';
@Input() transition: CarouselTransition = 'slide';
@Input() indicatorStyle: CarouselIndicatorStyle = 'dots';
@Input() disabled = false;
@Input() autoplay = false;
@Input() autoplayInterval = 3000;
@Input() loop = true;
@Input() showNavigation = true;
@Input() showIndicators = true;
@Input() ariaLabel = 'Image carousel';
@Input() initialIndex = 0;
@Output() slideChange = new EventEmitter<number>();
@Output() itemClick = new EventEmitter<{item: CarouselItem, index: number}>();
private _currentIndex = signal(this.initialIndex);
private _autoplayTimer?: number;
currentIndex = this._currentIndex.asReadonly();
getComponentClasses(): string {
const classes = [
'ui-carousel',
`ui-carousel--${this.size}`,
`ui-carousel--${this.variant}`,
`ui-carousel--${this.transition}`
];
if (this.disabled) {
classes.push('ui-carousel--disabled');
}
if (this.autoplay) {
classes.push('ui-carousel--autoplay');
}
return classes.join(' ');
}
getIndicatorClasses(): string {
const classes = ['ui-carousel__indicators'];
if (this.indicatorStyle) {
classes.push(`ui-carousel__indicators--${this.indicatorStyle}`);
}
return classes.join(' ');
}
ngOnInit(): void {
this._currentIndex.set(this.initialIndex);
if (this.autoplay && !this.disabled) {
this.startAutoplay();
}
}
ngOnDestroy(): void {
this.stopAutoplay();
}
goToSlide(index: number): void {
if (this.disabled || index < 0 || index >= this.items.length) {
return;
}
this._currentIndex.set(index);
this.slideChange.emit(index);
this.resetAutoplay();
}
goToNext(): void {
const nextIndex = this.loop && this.currentIndex() === this.items.length - 1
? 0
: Math.min(this.currentIndex() + 1, this.items.length - 1);
this.goToSlide(nextIndex);
}
goToPrevious(): void {
const prevIndex = this.loop && this.currentIndex() === 0
? this.items.length - 1
: Math.max(this.currentIndex() - 1, 0);
this.goToSlide(prevIndex);
}
private startAutoplay(): void {
this.stopAutoplay();
this._autoplayTimer = window.setInterval(() => {
this.goToNext();
}, this.autoplayInterval);
}
private stopAutoplay(): void {
if (this._autoplayTimer) {
clearInterval(this._autoplayTimer);
this._autoplayTimer = undefined;
}
}
private resetAutoplay(): void {
if (this.autoplay && !this.disabled) {
this.stopAutoplay();
this.startAutoplay();
}
}
}

View File

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

View File

@@ -0,0 +1,451 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-chip {
display: inline-flex;
align-items: center;
justify-content: center;
box-sizing: border-box;
position: relative;
vertical-align: top;
white-space: nowrap;
overflow: hidden;
margin-right: 0.75rem;
margin-bottom: 0.5rem;
// Prevent text selection
-webkit-touch-callout: none;
-webkit-user-select: none;
-khtml-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
// Base typography
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-weight: 500;
text-decoration: none;
outline: none;
// Transitions
transition: all $semantic-duration-fast $semantic-easing-standard;
// Size variants
&--sm {
height: 24px;
font-size: 0.75rem;
line-height: 1;
.chip-label {
padding: 0 0.5rem;
}
.chip-leading-icon,
.chip-trailing-icon {
width: 18px;
height: 18px;
font-size: 0.75rem;
margin: 0 0.25rem;
}
.chip-avatar {
width: 18px;
height: 18px;
margin: 0 0.25rem 0 0.125rem;
img {
width: 18px;
height: 18px;
border-radius: 9px;
}
}
&.ui-chip--with-leading-icon .chip-label,
&.ui-chip--with-avatar .chip-label {
padding-left: 0.25rem;
}
&.ui-chip--with-trailing-icon .chip-label,
&.ui-chip--removable .chip-label {
padding-right: 0.25rem;
}
}
&--md {
height: 32px;
font-size: 0.875rem;
line-height: 1;
.chip-label {
padding: 0 0.75rem;
}
.chip-leading-icon,
.chip-trailing-icon {
width: 24px;
height: 24px;
font-size: 1rem;
margin: 0 0.25rem;
}
.chip-avatar {
width: 24px;
height: 24px;
margin: 0 0.25rem 0 0.125rem;
img {
width: 24px;
height: 24px;
border-radius: 12px;
}
}
&.ui-chip--with-leading-icon .chip-label,
&.ui-chip--with-avatar .chip-label {
padding-left: 0.5rem;
}
&.ui-chip--with-trailing-icon .chip-label,
&.ui-chip--removable .chip-label {
padding-right: 0.5rem;
}
}
&--lg {
height: 40px;
font-size: 1rem;
line-height: 1;
.chip-label {
padding: 0 1rem;
}
.chip-leading-icon,
.chip-trailing-icon {
width: 28px;
height: 28px;
font-size: 1.125rem;
margin: 0 0.375rem;
}
.chip-avatar {
width: 32px;
height: 32px;
margin: 0 0.375rem 0 0.25rem;
img {
width: 32px;
height: 32px;
border-radius: 16px;
}
}
&.ui-chip--with-leading-icon .chip-label,
&.ui-chip--with-avatar .chip-label {
padding-left: 0.5rem;
}
&.ui-chip--with-trailing-icon .chip-label,
&.ui-chip--removable .chip-label {
padding-right: 0.5rem;
}
}
// Radius variants
&--radius-none {
border-radius: 0;
}
&--radius-sm {
border-radius: 0.25rem;
}
&--radius-md {
border-radius: 0.375rem;
}
&--radius-lg {
border-radius: 0.5rem;
}
&--radius-capsule {
border-radius: 50px;
}
// Variant styles
&--filled {
background-color: $semantic-color-surface-elevated;
color: $semantic-color-text-primary;
border: 1px solid transparent;
&:hover:not(.ui-chip--disabled) {
background-color: $semantic-color-surface-selected;
transform: translateY(-1px);
}
&:active:not(.ui-chip--disabled) {
transform: scale(0.95);
}
&.ui-chip--selected {
background-color: $semantic-color-primary;
color: $semantic-color-text-inverse;
&:hover:not(.ui-chip--disabled) {
background-color: $semantic-color-primary-hover;
}
}
}
&--outlined {
background-color: transparent;
color: $semantic-color-primary;
border: 1px solid $semantic-color-border-primary;
&:hover:not(.ui-chip--disabled) {
background-color: $semantic-color-surface-elevated;
border-color: $semantic-color-primary;
transform: translateY(-1px);
}
&:active:not(.ui-chip--disabled) {
background-color: $semantic-color-surface-primary;
transform: scale(0.95);
}
&.ui-chip--selected {
background-color: $semantic-color-surface-primary;
border-color: $semantic-color-primary;
&:hover:not(.ui-chip--disabled) {
background-color: $semantic-color-surface-elevated;
}
}
}
&--elevated {
background-color: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
border: none;
box-shadow: $semantic-shadow-elevation-1;
&:hover:not(.ui-chip--disabled) {
box-shadow: $semantic-shadow-elevation-2;
transform: translateY(-1px);
}
&:active:not(.ui-chip--disabled) {
box-shadow: $semantic-shadow-elevation-1;
transform: scale(0.95);
}
&.ui-chip--selected {
background-color: $semantic-color-primary;
color: $semantic-color-text-inverse;
box-shadow: $semantic-shadow-elevation-2;
&:hover:not(.ui-chip--disabled) {
background-color: $semantic-color-primary-hover;
box-shadow: $semantic-shadow-elevation-3;
}
}
}
// States
&--clickable {
cursor: pointer;
&:focus-visible {
outline: 2px solid $semantic-color-border-focus;
outline-offset: 2px;
}
}
&--disabled {
opacity: 0.38;
cursor: not-allowed;
pointer-events: none;
}
&--selected {
// Selected styles are handled in variant sections above
}
// Icon and avatar styles
.chip-leading-icon,
.chip-trailing-icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.chip-trailing-icon {
cursor: pointer;
border-radius: 50%;
transition: background-color $semantic-duration-fast ease;
&:hover {
background-color: rgba(0, 0, 0, 0.1);
}
&:focus-visible {
outline: 1px solid $semantic-color-border-focus;
outline-offset: 1px;
}
}
.chip-avatar {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
overflow: hidden;
img {
object-fit: cover;
border: 1px solid rgba(0, 0, 0, 0.1);
}
}
.chip-label {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-width: 0;
text-align: center;
}
}
// Dark mode support
@media (prefers-color-scheme: dark) {
.ui-chip {
&--filled {
background-color: hsl(279, 14%, 25%);
color: hsl(0, 0%, 90%);
&:hover:not(.ui-chip--disabled) {
background-color: hsl(279, 14%, 30%);
}
&.ui-chip--selected {
background-color: hsl(258, 100%, 47%);
color: white;
&:hover:not(.ui-chip--disabled) {
background-color: hsl(258, 100%, 52%);
}
}
}
&--outlined {
background-color: transparent;
color: hsl(258, 100%, 67%);
border-color: hsl(279, 14%, 45%);
&:hover:not(.ui-chip--disabled) {
background-color: hsl(279, 14%, 15%);
border-color: hsl(258, 100%, 67%);
}
&:active:not(.ui-chip--disabled) {
background-color: hsl(279, 14%, 20%);
}
&.ui-chip--selected {
background-color: hsl(279, 14%, 20%);
border-color: hsl(258, 100%, 67%);
&:hover:not(.ui-chip--disabled) {
background-color: hsl(279, 14%, 25%);
}
}
}
&--elevated {
background-color: hsl(279, 14%, 15%);
color: hsl(0, 0%, 90%);
&:hover:not(.ui-chip--disabled) {
background-color: hsl(279, 14%, 20%);
}
&.ui-chip--selected {
background-color: hsl(258, 100%, 47%);
color: white;
&:hover:not(.ui-chip--disabled) {
background-color: hsl(258, 100%, 52%);
}
}
}
.chip-trailing-icon:hover {
background-color: rgba(255, 255, 255, 0.1);
}
.chip-avatar img {
border-color: rgba(255, 255, 255, 0.2);
}
}
}
// Accessibility
@media (prefers-reduced-motion: reduce) {
.ui-chip {
transition: none;
&:hover,
&:active {
transform: none;
}
}
}
// Chip group/set container utility
.ui-chip-set {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
align-items: flex-start;
&--row {
flex-wrap: nowrap;
overflow-x: auto;
scroll-padding-left: 1rem;
scroll-snap-type: x mandatory;
.ui-chip {
scroll-snap-align: center;
flex-shrink: 0;
}
&::-webkit-scrollbar {
height: 0;
background: transparent;
}
scrollbar-width: none;
-ms-overflow-style: none;
}
&--no-scroll {
overflow-x: visible;
}
}
// Responsive
@media (max-width: 768px) {
.ui-chip {
margin-right: 0.5rem;
margin-bottom: 0.375rem;
&--lg {
height: 36px;
font-size: 0.875rem;
.chip-label {
padding: 0 0.75rem;
}
}
}
}

View File

@@ -0,0 +1,105 @@
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 ChipVariant = 'filled' | 'outlined' | 'elevated';
export type ChipSize = 'sm' | 'md' | 'lg';
export type ChipRadius = 'none' | 'sm' | 'md' | 'lg' | 'capsule';
@Component({
selector: 'ui-chip',
standalone: true,
imports: [CommonModule, FontAwesomeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div
[class]="chipClasses"
[attr.tabindex]="disabled ? null : '0'"
[attr.role]="clickable ? 'button' : null"
[attr.aria-disabled]="disabled"
[attr.aria-selected]="selected"
(click)="handleClick($event)"
(keydown.enter)="handleClick($event)"
(keydown.space)="handleClick($event)"
>
@if (leadingIcon) {
<div class="chip-leading-icon">
<fa-icon [icon]="leadingIcon"></fa-icon>
</div>
}
@if (avatar) {
<div class="chip-avatar">
<img [src]="avatar" [alt]="avatarAlt || label" />
</div>
}
<div class="chip-label">
{{ label }}
</div>
@if (removable && closeIcon) {
<div class="chip-trailing-icon" (click)="handleRemove($event)" [attr.tabindex]="disabled ? null : '0'">
<fa-icon [icon]="closeIcon"></fa-icon>
</div>
}
@if (trailingIcon && !removable) {
<div class="chip-trailing-icon">
<fa-icon [icon]="trailingIcon"></fa-icon>
</div>
}
</div>
`,
styleUrl: './chip.component.scss'
})
export class ChipComponent {
@Input() label: string = '';
@Input() variant: ChipVariant = 'filled';
@Input() size: ChipSize = 'md';
@Input() radius: ChipRadius = 'md';
@Input() disabled: boolean = false;
@Input() selected: boolean = false;
@Input() clickable: boolean = false;
@Input() removable: boolean = false;
@Input() leadingIcon?: IconDefinition;
@Input() trailingIcon?: IconDefinition;
@Input() closeIcon?: IconDefinition;
@Input() avatar?: string;
@Input() avatarAlt?: string;
@Input() class: string = '';
@Output() chipClick = new EventEmitter<Event>();
@Output() chipRemove = new EventEmitter<Event>();
get chipClasses(): string {
return [
'ui-chip',
`ui-chip--${this.variant}`,
`ui-chip--${this.size}`,
`ui-chip--radius-${this.radius}`,
this.disabled ? 'ui-chip--disabled' : '',
this.selected ? 'ui-chip--selected' : '',
this.clickable ? 'ui-chip--clickable' : '',
this.removable ? 'ui-chip--removable' : '',
this.leadingIcon ? 'ui-chip--with-leading-icon' : '',
this.trailingIcon ? 'ui-chip--with-trailing-icon' : '',
this.avatar ? 'ui-chip--with-avatar' : '',
this.class
].filter(Boolean).join(' ');
}
handleClick(event: Event): void {
if (!this.disabled && this.clickable) {
this.chipClick.emit(event);
}
}
handleRemove(event: Event): void {
event.stopPropagation();
if (!this.disabled) {
this.chipRemove.emit(event);
}
}
}

View File

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

View File

@@ -0,0 +1,314 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
// Tokens available globally via main application styles
.ui-image-container {
// Core Structure
position: relative;
display: inline-block;
overflow: hidden;
// Layout & Aspect Ratio
aspect-ratio: var(--aspect-ratio, 1/1);
// Visual Design
background: $semantic-color-surface-secondary;
border: $semantic-border-width-1 solid $semantic-color-border-primary;
border-radius: $semantic-border-radius-md;
// Transitions
transition: all $semantic-duration-fast $semantic-easing-standard;
// Size Variants
&--size-sm {
min-width: $semantic-sizing-card-width-sm * 0.5; // 8rem
max-width: $semantic-sizing-card-width-sm; // 16rem
min-height: $semantic-sizing-card-height-sm * 0.5; // 4rem
}
&--size-md {
min-width: $semantic-sizing-card-width-md * 0.5; // 10rem
max-width: $semantic-sizing-card-width-md; // 20rem
min-height: $semantic-sizing-card-height-md * 0.5; // 6rem
}
&--size-lg {
min-width: $semantic-sizing-card-width-lg * 0.5; // 12rem
max-width: $semantic-sizing-card-width-lg; // 24rem
min-height: $semantic-sizing-card-height-lg * 0.5; // 8rem
}
&--size-xl {
min-width: $semantic-sizing-card-width-lg * 0.75; // 18rem
max-width: $semantic-sizing-content-narrow; // 42rem
min-height: $semantic-sizing-card-height-lg * 0.75; // 12rem
}
// Aspect Ratio Variants
&--aspect-1-1 { aspect-ratio: 1/1; }
&--aspect-4-3 { aspect-ratio: 4/3; }
&--aspect-16-9 { aspect-ratio: 16/9; }
&--aspect-3-2 { aspect-ratio: 3/2; }
&--aspect-2-1 { aspect-ratio: 2/1; }
&--aspect-3-4 { aspect-ratio: 3/4; }
&--aspect-9-16 { aspect-ratio: 9/16; }
// Shape Variants
&--shape-square {
border-radius: $semantic-border-radius-sm;
}
&--shape-rounded {
border-radius: $semantic-border-radius-md;
}
&--shape-circle {
border-radius: $semantic-border-radius-full;
aspect-ratio: 1/1;
}
// State Variants
&--loading {
cursor: wait;
}
&--error {
border-color: $semantic-color-border-error;
border-style: dashed;
}
&--lazy {
background: $semantic-color-surface-secondary;
&:not(.ui-image-container--loaded) {
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
}
// Interactive States
&:not(.ui-image-container--error) {
&:hover {
box-shadow: $semantic-shadow-elevation-2;
transform: translateY(-1px);
border-color: $semantic-color-border-primary;
}
&:focus-visible {
outline: 2px solid $semantic-color-focus-ring;
outline-offset: 2px;
}
&:active {
transform: translateY(0);
box-shadow: $semantic-shadow-elevation-1;
}
}
// Image Element
&__image {
width: 100%;
height: 100%;
display: block;
// Object Fit Variants
&--fit-contain { object-fit: contain; }
&--fit-cover { object-fit: cover; }
&--fit-fill { object-fit: fill; }
&--fit-scale-down { object-fit: scale-down; }
&--fit-none { object-fit: none; }
// Smooth appearance transition
opacity: 0;
transition: opacity $semantic-duration-medium $semantic-easing-standard;
.ui-image-container--loaded & {
opacity: 1;
}
}
// Loading State
&__loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: $semantic-color-surface-secondary;
z-index: 2;
}
&__spinner {
width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
border: 2px solid $semantic-color-border-primary;
border-top: 2px solid $semantic-color-interactive-primary;
border-radius: $semantic-border-radius-full;
animation: spin 1s linear infinite;
}
// Error State
&__error {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $semantic-spacing-component-xs;
background: $semantic-color-surface-secondary;
color: $semantic-color-danger;
z-index: 2;
padding: $semantic-spacing-component-sm;
text-align: center;
}
&__error-icon {
color: $semantic-color-text-secondary;
svg {
width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
}
}
&__error-text {
font-size: $semantic-typography-font-size-xs;
color: $semantic-color-text-secondary;
font-weight: $semantic-typography-font-weight-medium;
}
// Overlay
&__overlay {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.4);
opacity: 0;
transition: opacity $semantic-duration-fast $semantic-easing-standard;
z-index: 3;
.ui-image-container:hover & {
opacity: 1;
}
}
// Caption
&__caption {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0, 0, 0, 0.7));
color: white;
padding: $semantic-spacing-component-md $semantic-spacing-component-sm $semantic-spacing-component-sm;
font-size: $semantic-typography-font-size-sm;
line-height: $semantic-typography-line-height-tight;
z-index: 4;
}
// Dark Mode Support
:host-context(.dark-theme) & {
background: $semantic-color-surface-primary;
border-color: $semantic-color-border-subtle;
&__loading {
background: $semantic-color-surface-primary;
}
&__spinner {
border-color: $semantic-color-border-subtle;
border-top-color: $semantic-color-interactive-primary;
}
}
// Responsive Design
@media (max-width: $semantic-sizing-breakpoint-tablet - 1) {
&--size-xl {
max-width: $semantic-sizing-card-width-lg;
}
&--size-lg {
max-width: $semantic-sizing-card-width-md;
}
}
@media (max-width: $semantic-sizing-breakpoint-mobile - 1) {
// Mobile adjustments
&__caption {
font-size: $semantic-typography-font-size-xs;
padding: $semantic-spacing-component-xs;
}
&__error-text {
font-size: $semantic-typography-font-size-xs;
}
}
// Accessibility - Reduced motion
@media (prefers-reduced-motion: reduce) {
transition: none;
&__image {
transition: none;
}
&__overlay {
transition: none;
}
&--lazy:not(.ui-image-container--loaded) {
animation: none;
}
&__spinner {
animation: none;
}
}
}
// Animation Keyframes
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
// Utility classes for content projection
.ui-image-container {
[slot='error'] {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: $semantic-spacing-component-xs;
padding: $semantic-spacing-component-sm;
text-align: center;
height: 100%;
color: $semantic-color-danger;
}
[slot='overlay'] {
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: $semantic-typography-font-weight-medium;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
}
[slot='caption'] {
font-size: $semantic-typography-font-size-sm;
line-height: $semantic-typography-line-height-tight;
}
}

View File

@@ -0,0 +1,217 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, ElementRef, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
export type ImageContainerSize = 'sm' | 'md' | 'lg' | 'xl';
export type ImageContainerAspectRatio = '1/1' | '4/3' | '16/9' | '3/2' | '2/1' | '3/4' | '9/16';
export type ImageContainerObjectFit = 'contain' | 'cover' | 'fill' | 'scale-down' | 'none';
export type ImageContainerShape = 'square' | 'rounded' | 'circle';
@Component({
selector: 'ui-image-container',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-image-container"
[class]="getContainerClasses()"
[class.ui-image-container--loading]="loading"
[class.ui-image-container--error]="hasError"
[class.ui-image-container--lazy]="lazy"
[class.ui-image-container--loaded]="isLoaded"
[style.--aspect-ratio]="aspectRatio"
[attr.aria-label]="ariaLabel"
(click)="handleClick($event)">
@if (loading && !isLoaded) {
<div class="ui-image-container__loading" aria-hidden="true">
<div class="ui-image-container__spinner"></div>
</div>
}
@if (hasError) {
<div class="ui-image-container__error" role="img" [attr.aria-label]="errorAlt || 'Image failed to load'">
<ng-content select="[slot='error']">
<div class="ui-image-container__error-icon" aria-hidden="true">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<rect x="3" y="3" width="18" height="18" rx="2" ry="2"/>
<circle cx="8.5" cy="8.5" r="1.5"/>
<polyline points="21,15 16,10 5,21"/>
</svg>
</div>
<span class="ui-image-container__error-text">Failed to load image</span>
</ng-content>
</div>
} @else {
<img
#imageElement
class="ui-image-container__image"
[class]="getImageClasses()"
[src]="currentSrc"
[alt]="alt || ''"
[loading]="lazy ? 'lazy' : 'eager'"
[style.object-fit]="objectFit"
(load)="handleLoad($event)"
(error)="handleError($event)"
(loadstart)="handleLoadStart()"
[attr.crossorigin]="crossorigin"
[attr.referrerpolicy]="referrerPolicy"
[attr.sizes]="sizes"
[attr.srcset]="srcset">
}
@if (overlay) {
<div class="ui-image-container__overlay">
<ng-content select="[slot='overlay']"></ng-content>
</div>
}
@if (caption) {
<div class="ui-image-container__caption">
<ng-content select="[slot='caption']">
<span>{{ caption }}</span>
</ng-content>
</div>
}
</div>
`,
styleUrl: './image-container.component.scss'
})
export class ImageContainerComponent implements OnInit, OnDestroy {
@Input() src!: string;
@Input() alt = '';
@Input() size: ImageContainerSize = 'md';
@Input() aspectRatio: ImageContainerAspectRatio = '1/1';
@Input() objectFit: ImageContainerObjectFit = 'cover';
@Input() shape: ImageContainerShape = 'rounded';
@Input() lazy = true;
@Input() loading = false;
@Input() placeholder?: string;
@Input() caption?: string;
@Input() overlay = false;
@Input() ariaLabel?: string;
@Input() errorAlt?: string;
// Advanced image attributes
@Input() srcset?: string;
@Input() sizes?: string;
@Input() crossorigin?: 'anonymous' | 'use-credentials';
@Input() referrerPolicy?: 'no-referrer' | 'no-referrer-when-downgrade' | 'origin' | 'origin-when-cross-origin' | 'same-origin' | 'strict-origin' | 'strict-origin-when-cross-origin' | 'unsafe-url';
@Output() imageLoaded = new EventEmitter<Event>();
@Output() imageError = new EventEmitter<Event>();
@Output() imageClick = new EventEmitter<MouseEvent>();
@Output() loadingChange = new EventEmitter<boolean>();
private elementRef = inject(ElementRef);
private intersectionObserver?: IntersectionObserver;
hasError = false;
isLoaded = false;
currentSrc = '';
ngOnInit(): void {
this.initializeImage();
if (this.lazy && this.supportsIntersectionObserver()) {
this.setupLazyLoading();
} else {
this.loadImage();
}
}
ngOnDestroy(): void {
if (this.intersectionObserver) {
this.intersectionObserver.disconnect();
}
}
private initializeImage(): void {
this.currentSrc = this.placeholder || this.src;
this.hasError = false;
this.isLoaded = false;
}
private setupLazyLoading(): void {
if (!this.supportsIntersectionObserver()) {
this.loadImage();
return;
}
this.intersectionObserver = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
this.loadImage();
this.intersectionObserver?.unobserve(entry.target);
}
});
},
{
rootMargin: '50px 0px',
threshold: 0.01
}
);
this.intersectionObserver.observe(this.elementRef.nativeElement);
}
private loadImage(): void {
if (this.src && !this.isLoaded && !this.hasError) {
this.currentSrc = this.src;
this.loading = true;
this.loadingChange.emit(true);
}
}
private supportsIntersectionObserver(): boolean {
return 'IntersectionObserver' in window;
}
handleLoadStart(): void {
this.loading = true;
this.loadingChange.emit(true);
}
handleLoad(event: Event): void {
this.loading = false;
this.isLoaded = true;
this.hasError = false;
this.loadingChange.emit(false);
this.imageLoaded.emit(event);
}
handleError(event: Event): void {
this.loading = false;
this.hasError = true;
this.isLoaded = false;
this.loadingChange.emit(false);
this.imageError.emit(event);
}
handleClick(event: MouseEvent): void {
if (!this.hasError) {
this.imageClick.emit(event);
}
}
// Method to retry loading failed images
retryLoad(): void {
this.hasError = false;
this.isLoaded = false;
this.loadImage();
}
getContainerClasses(): string {
const aspectClass = `ui-image-container--aspect-${this.aspectRatio.replace('/', '-')}`;
const sizeClass = `ui-image-container--size-${this.size}`;
const shapeClass = `ui-image-container--shape-${this.shape}`;
return `ui-image-container ${sizeClass} ${aspectClass} ${shapeClass}`;
}
getImageClasses(): string {
const fitClass = `ui-image-container__image--fit-${this.objectFit}`;
return `ui-image-container__image ${fitClass}`;
}
}

View File

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

View File

@@ -0,0 +1,17 @@
export * from './accordion';
export * from './avatar';
export * from './badge';
export * from './card';
export * from './carousel';
export * from './chip';
export * from './image-container';
export * from './list';
export * from './progress';
export * from './table';
export * from './timeline';
export * from './tooltip';
export * from './transfer-list';
export * from './tree-view';
// Selectively export from feedback to avoid ProgressBarComponent conflict
export { StatusBadgeComponent } from '../feedback';
export type { StatusBadgeVariant, StatusBadgeSize, StatusBadgeShape } from '../feedback';

View File

@@ -0,0 +1,223 @@
# List Components
Comprehensive list component system built with Angular 19+ and semantic design tokens. Features multiple variants, sizes, and interactive states for maximum flexibility.
## Components
### `ListItemComponent`
Individual list item with support for avatars, media, icons, and multiple text lines.
### `ListContainerComponent`
Container for list items with elevation, spacing, and scrolling capabilities.
### `ListExamplesComponent`
Demonstration component showcasing all variants and usage patterns.
## Features
- **Multiple Sizes**: `sm`, `md`, `lg`
- **Line Variants**: One, two, or three lines of text
- **Content Types**: Text-only, avatar, media, icon
- **Interactive States**: Hover, focus, selected, disabled
- **Container Options**: Elevation, spacing, scrolling, rounded corners
- **Text Overflow**: Automatic ellipsis handling
- **Accessibility**: Full ARIA support and keyboard navigation
- **Responsive**: Mobile-first design with breakpoint adjustments
## Usage
### Basic Text List
```typescript
import { ListItemComponent, ListContainerComponent } from './shared/components';
// In your component:
items = [
{ primary: 'Inbox' },
{ primary: 'Starred' },
{ primary: 'Sent' }
];
```
```html
<ui-list-container elevation="sm" spacing="xs" rounded>
@for (item of items; track item.primary) {
<ui-list-item
[data]="item"
size="md"
lines="one"
variant="text"
/>
}
</ui-list-container>
```
### Avatar List with Two Lines
```typescript
avatarItems = [
{
primary: 'John Doe',
secondary: 'Software Engineer',
avatarSrc: 'https://example.com/avatar.jpg',
avatarAlt: 'John Doe'
}
];
```
```html
<ui-list-container elevation="md" spacing="sm">
@for (item of avatarItems; track item.primary) {
<ui-list-item
[data]="item"
size="lg"
lines="two"
variant="avatar"
>
<button slot="trailing" class="action-btn">
<i class="fas fa-more-vert"></i>
</button>
</ui-list-item>
}
</ui-list-container>
```
### Media List with Three Lines
```typescript
mediaItems = [
{
primary: 'Angular Course',
secondary: 'Complete guide to modern development',
tertiary: 'Duration: 2h 30m • Updated: Today',
mediaSrc: 'https://example.com/thumb.jpg',
mediaAlt: 'Course thumbnail'
}
];
```
```html
<ui-list-container elevation="lg" spacing="md" rounded>
@for (item of mediaItems; track item.primary) {
<ui-list-item
[data]="item"
size="lg"
lines="three"
variant="media"
>
<div slot="trailing" class="rating">
<i class="fas fa-star"></i>
<span>4.8</span>
</div>
</ui-list-item>
}
</ui-list-container>
```
### Scrollable List
```html
<ui-list-container
elevation="md"
spacing="xs"
[scrollable]="true"
maxHeight="400px"
[fadeIndicator]="true"
rounded
>
@for (item of longList; track item.id) {
<ui-list-item [data]="item" size="md" lines="two" variant="avatar" />
}
</ui-list-container>
```
## API
### ListItemComponent Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `data` | `ListItemData` | required | Item content and configuration |
| `size` | `'sm' \| 'md' \| 'lg'` | `'md'` | Item height and spacing |
| `lines` | `'one' \| 'two' \| 'three'` | `'one'` | Number of text lines |
| `variant` | `'text' \| 'avatar' \| 'media'` | `'text'` | Content type |
| `interactive` | `boolean` | `true` | Enable hover/focus states |
| `divider` | `boolean` | `false` | Show bottom border |
### ListContainerComponent Props
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| `elevation` | `'none' \| 'sm' \| 'md' \| 'lg'` | `'none'` | Shadow elevation |
| `spacing` | `'none' \| 'xs' \| 'sm' \| 'md' \| 'lg'` | `'sm'` | Item spacing |
| `scrollable` | `boolean` | `false` | Enable vertical scrolling |
| `maxHeight` | `string?` | - | Maximum container height |
| `fadeIndicator` | `boolean` | `true` | Show fade at scroll bottom |
| `dense` | `boolean` | `false` | Reduce spacing between items |
| `rounded` | `boolean` | `false` | Round container corners |
| `ariaLabel` | `string?` | - | Accessibility label |
### ListItemData Interface
```typescript
interface ListItemData {
primary: string; // Main text (required)
secondary?: string; // Secondary text
tertiary?: string; // Tertiary text (three-line only)
avatarSrc?: string; // Avatar image URL
avatarAlt?: string; // Avatar alt text
mediaSrc?: string; // Media image URL
mediaAlt?: string; // Media alt text
icon?: string; // Icon class name
disabled?: boolean; // Disabled state
selected?: boolean; // Selected state
}
```
## Slots
### Trailing Content
Use the `slot="trailing"` attribute to add content to the right side of list items:
```html
<ui-list-item [data]="item">
<button slot="trailing">Action</button>
</ui-list-item>
```
## Design Tokens
The components use semantic design tokens for consistent styling:
- **Colors**: `$semantic-color-*`
- **Spacing**: `$semantic-spacing-*`
- **Shadows**: `$semantic-shadow-*`
- **Borders**: `$semantic-border-radius-*`
## Accessibility
- Full ARIA support with proper roles and labels
- Keyboard navigation support
- Focus management and indicators
- Screen reader friendly
- High contrast mode support
- Reduced motion support
## Responsive Design
- Mobile-first approach
- Breakpoint-specific adjustments
- Touch-friendly tap targets
- Optimized spacing for different screen sizes
## Examples
Import and use the `ListExamplesComponent` to see all variants in action:
```typescript
import { ListExamplesComponent } from './shared/components';
```
```html
<ui-list-examples></ui-list-examples>
```

View File

@@ -0,0 +1,23 @@
// ==========================================================================
// LIST COMPONENTS
// ==========================================================================
// Comprehensive list component system with multiple variants and sizes
// Built with Angular 19+ and semantic design tokens
// ==========================================================================
export * from './list-item.component';
export * from './list-container.component';
export * from './list-examples.component';
// Re-export types for convenience
export type {
ListItemSize,
ListItemLines,
ListItemVariant,
ListItemData
} from './list-item.component';
export type {
ListContainerElevation,
ListContainerSpacing
} from './list-container.component';

View File

@@ -0,0 +1,263 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
// Tokens available globally via main application styles
// ==========================================================================
// LIST CONTAINER COMPONENT
// ==========================================================================
// Container component for list items with elevation, spacing, and scrolling
// Provides consistent layout and styling using semantic design tokens
// ==========================================================================
.list-container {
display: flex;
flex-direction: column;
width: 100%;
box-sizing: border-box;
background-color: $semantic-color-surface-primary;
position: relative;
overflow: hidden;
// ==========================================================================
// ELEVATION VARIANTS
// ==========================================================================
&--elevation-none {
box-shadow: none;
}
&--elevation-sm {
box-shadow: $semantic-shadow-elevation-1;
}
&--elevation-md {
box-shadow: $semantic-shadow-elevation-3;
}
&--elevation-lg {
box-shadow: $semantic-shadow-elevation-4;
}
// ==========================================================================
// SPACING VARIANTS
// ==========================================================================
&--spacing-none {
padding: 0;
.list-item:not(:last-child) {
margin-bottom: 0;
}
}
&--spacing-xs {
padding: $semantic-spacing-component-xs 0;
.list-item:not(:last-child) {
margin-bottom: $semantic-spacing-component-xs;
}
}
&--spacing-sm {
padding: $semantic-spacing-component-sm 0;
.list-item:not(:last-child) {
margin-bottom: $semantic-spacing-component-sm;
}
}
&--spacing-md {
padding: $semantic-spacing-component-md 0;
.list-item:not(:last-child) {
margin-bottom: $semantic-spacing-component-md;
}
}
&--spacing-lg {
padding: $semantic-spacing-component-lg 0;
.list-item:not(:last-child) {
margin-bottom: $semantic-spacing-component-lg;
}
}
// ==========================================================================
// LAYOUT MODIFIERS
// ==========================================================================
&--scrollable {
overflow-y: auto;
-webkit-overflow-scrolling: touch;
// Custom scrollbar styling
&::-webkit-scrollbar {
width: 6px;
}
&::-webkit-scrollbar-track {
background: $semantic-color-surface-secondary;
border-radius: 3px;
}
&::-webkit-scrollbar-thumb {
background: $semantic-color-border-primary;
border-radius: 3px;
&:hover {
background: $semantic-color-border-secondary;
}
}
// Firefox scrollbar
scrollbar-width: thin;
scrollbar-color: $semantic-color-border-primary $semantic-color-surface-secondary;
}
&--max-height {
height: var(--list-max-height);
max-height: var(--list-max-height);
}
&--dense {
&.list-container--spacing-xs .list-item:not(:last-child) {
margin-bottom: $semantic-spacing-micro-tight;
}
&.list-container--spacing-sm .list-item:not(:last-child) {
margin-bottom: $semantic-spacing-component-xs;
}
&.list-container--spacing-md .list-item:not(:last-child) {
margin-bottom: $semantic-spacing-component-sm;
}
&.list-container--spacing-lg .list-item:not(:last-child) {
margin-bottom: $semantic-spacing-component-md;
}
}
&--rounded {
border-radius: $semantic-border-radius-md;
overflow: hidden;
.list-item:first-child {
border-top-left-radius: $semantic-border-radius-md;
border-top-right-radius: $semantic-border-radius-md;
}
.list-item:last-child {
border-bottom-left-radius: $semantic-border-radius-md;
border-bottom-right-radius: $semantic-border-radius-md;
}
}
}
// ==========================================================================
// FADE INDICATOR
// ==========================================================================
.list-container__fade-bottom {
position: absolute;
bottom: 0;
left: 0;
right: 0;
height: 24px;
background: linear-gradient(
to bottom,
transparent 0%,
$semantic-color-surface-primary 100%
);
pointer-events: none;
opacity: 0;
transition: opacity 0.2s ease-in-out;
.list-container--scrollable:not(.list-container--at-bottom) & {
opacity: 1;
}
}
// ==========================================================================
// DYNAMIC HEIGHT SETUP
// ==========================================================================
// JavaScript will set this CSS custom property
.list-container--max-height {
--list-max-height: 400px;
}
// ==========================================================================
// RESPONSIVE ADJUSTMENTS
// ==========================================================================
@media (max-width: 768px) {
.list-container {
&--spacing-md {
padding: $semantic-spacing-component-sm 0;
.list-item:not(:last-child) {
margin-bottom: $semantic-spacing-component-sm;
}
}
&--spacing-lg {
padding: $semantic-spacing-component-md 0;
.list-item:not(:last-child) {
margin-bottom: $semantic-spacing-component-md;
}
}
}
}
// ==========================================================================
// ACCESSIBILITY ENHANCEMENTS
// ==========================================================================
.list-container {
&:focus-within {
outline: 2px solid $semantic-color-border-focus;
outline-offset: 2px;
}
}
// ==========================================================================
// ANIMATION UTILITIES
// ==========================================================================
.list-container {
// Smooth height transitions when content changes
transition: height 0.2s ease-in-out;
}
// List item enter/leave animations
@keyframes list-item-enter {
from {
opacity: 0;
transform: translateY(-8px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes list-item-leave {
from {
opacity: 1;
transform: translateY(0);
}
to {
opacity: 0;
transform: translateY(-8px);
}
}
// Add these classes via JavaScript for dynamic content
.list-item-enter {
animation: list-item-enter 0.2s ease-out;
}
.list-item-leave {
animation: list-item-leave 0.2s ease-out;
}

View File

@@ -0,0 +1,52 @@
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
export type ListContainerElevation = 'none' | 'sm' | 'md' | 'lg';
export type ListContainerSpacing = 'none' | 'xs' | 'sm' | 'md' | 'lg';
@Component({
selector: 'ui-list-container',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
template: `
<div
class="list-container"
[class]="getContainerClasses()"
role="list"
[attr.aria-label]="ariaLabel"
>
<ng-content></ng-content>
<!-- Fade indicator for scrollable content -->
@if (scrollable && fadeIndicator) {
<div class="list-container__fade-bottom"></div>
}
</div>
`,
styleUrls: ['./list-container.component.scss']
})
export class ListContainerComponent {
@Input() elevation: ListContainerElevation = 'none';
@Input() spacing: ListContainerSpacing = 'sm';
@Input() scrollable = false;
@Input() maxHeight?: string;
@Input() fadeIndicator = true;
@Input() dense = false;
@Input() rounded = false;
@Input() ariaLabel?: string;
getContainerClasses(): string {
const classes = [
`list-container--elevation-${this.elevation}`,
`list-container--spacing-${this.spacing}`
];
if (this.scrollable) classes.push('list-container--scrollable');
if (this.dense) classes.push('list-container--dense');
if (this.rounded) classes.push('list-container--rounded');
if (this.maxHeight) classes.push('list-container--max-height');
return classes.join(' ');
}
}

View File

@@ -0,0 +1,234 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
// Tokens available globally via main application styles
// ==========================================================================
// LIST EXAMPLES COMPONENT
// ==========================================================================
// Demonstration component showcasing all list variants and capabilities
// ==========================================================================
.list-examples {
padding: $semantic-spacing-component-xl;
max-width: 1200px;
margin: 0 auto;
h2 {
font-size: 2rem;
font-weight: 600;
color: $semantic-color-text-primary;
margin-bottom: $semantic-spacing-layout-lg;
text-align: center;
}
}
.example-section {
margin-bottom: $semantic-spacing-layout-xl;
h3 {
font-size: 1.5rem;
font-weight: 500;
color: $semantic-color-text-primary;
margin-bottom: $semantic-spacing-component-xl;
padding-bottom: $semantic-spacing-component-sm;
border-bottom: 2px solid $semantic-color-border-secondary;
}
}
.example-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: $semantic-spacing-component-xl;
margin-bottom: $semantic-spacing-layout-md;
}
.example-item {
h4 {
font-size: 1rem;
font-weight: 500;
color: $semantic-color-text-secondary;
margin-bottom: $semantic-spacing-component-md;
text-align: center;
}
}
.example-single {
max-width: 600px;
margin: 0 auto;
}
// ==========================================================================
// TRAILING SLOT COMPONENTS
// ==========================================================================
.action-button {
background: none;
border: none;
color: $semantic-color-text-tertiary;
cursor: pointer;
padding: $semantic-spacing-component-xs;
border-radius: $semantic-border-radius-sm;
transition: all 0.15s ease-in-out;
&:hover {
background-color: $semantic-color-surface-interactive;
color: $semantic-color-text-secondary;
}
&:focus {
outline: 2px solid $semantic-color-border-focus;
outline-offset: 2px;
}
i {
font-size: 14px;
}
}
.timestamp {
font-size: 0.75rem;
color: $semantic-color-text-tertiary;
font-weight: 400;
}
.play-button {
width: 32px;
height: 32px;
border-radius: 50%;
background-color: $semantic-color-interactive-primary;
color: $semantic-color-on-brand-primary;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease-in-out;
&:hover {
transform: scale(1.05);
box-shadow: $semantic-shadow-elevation-1;
}
&:focus {
outline: 2px solid $semantic-color-border-focus;
outline-offset: 2px;
}
i {
font-size: 12px;
margin-left: 2px; // Optical alignment for play icon
}
}
.rating {
display: flex;
align-items: center;
gap: $semantic-spacing-micro-tight;
color: $semantic-color-warning;
font-size: 0.875rem;
font-weight: 500;
i {
font-size: 14px;
}
}
.select-button {
background-color: $semantic-color-interactive-primary;
color: $semantic-color-on-brand-primary;
border: none;
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
border-radius: $semantic-border-radius-sm;
font-size: 0.875rem;
font-weight: 500;
cursor: pointer;
transition: all 0.15s ease-in-out;
&:hover {
background-color: $semantic-color-interactive-secondary;
}
&:focus {
outline: 2px solid $semantic-color-border-focus;
outline-offset: 2px;
}
}
.text-success {
color: $semantic-color-success;
}
// ==========================================================================
// RESPONSIVE DESIGN
// ==========================================================================
@media (max-width: 768px) {
.list-examples {
padding: $semantic-spacing-component-lg;
h2 {
font-size: 1.5rem;
}
}
.example-grid {
grid-template-columns: 1fr;
gap: $semantic-spacing-component-lg;
}
.example-section {
margin-bottom: $semantic-spacing-layout-md;
h3 {
font-size: 1.25rem;
}
}
}
@media (max-width: 480px) {
.list-examples {
padding: $semantic-spacing-component-md;
}
.example-grid {
gap: $semantic-spacing-component-md;
}
}
// ==========================================================================
// ACCESSIBILITY ENHANCEMENTS
// ==========================================================================
// Focus indicators for better keyboard navigation
.list-examples {
button:focus,
.action-button:focus,
.play-button:focus,
.select-button:focus {
outline: 2px solid $semantic-color-border-focus;
outline-offset: 2px;
border-radius: $semantic-border-radius-sm;
}
}
// High contrast mode support
@media (prefers-contrast: high) {
.action-button,
.play-button,
.select-button {
border: 1px solid $semantic-color-border-primary;
}
}
// Reduced motion support
@media (prefers-reduced-motion: reduce) {
.action-button,
.play-button,
.select-button {
transition: none;
}
.play-button:hover {
transform: none;
}
}

View File

@@ -0,0 +1,374 @@
import { Component, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { ListItemComponent, ListItemData } from './list-item.component';
import { ListContainerComponent } from './list-container.component';
@Component({
selector: 'ui-list-examples',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule, ListItemComponent, ListContainerComponent],
template: `
<div class="list-examples">
<h2>List Component Examples</h2>
<!-- Basic Text Lists -->
<section class="example-section">
<h3>Text Lists - Different Sizes</h3>
<div class="example-grid">
<div class="example-item">
<h4>Small (sm)</h4>
<ui-list-container elevation="sm" spacing="xs" [rounded]="true">
@for (item of basicTextItems; track item.primary) {
<ui-list-item
[data]="item"
size="sm"
lines="one"
variant="text"
[divider]="true"
/>
}
</ui-list-container>
</div>
<div class="example-item">
<h4>Medium (md)</h4>
<ui-list-container elevation="sm" spacing="xs" [rounded]="true">
@for (item of basicTextItems; track item.primary) {
<ui-list-item
[data]="item"
size="md"
lines="one"
variant="text"
[divider]="true"
/>
}
</ui-list-container>
</div>
<div class="example-item">
<h4>Large (lg)</h4>
<ui-list-container elevation="sm" spacing="xs" [rounded]="true">
@for (item of basicTextItems; track item.primary) {
<ui-list-item
[data]="item"
size="lg"
lines="one"
variant="text"
[divider]="true"
/>
}
</ui-list-container>
</div>
</div>
</section>
<!-- Multi-line Lists -->
<section class="example-section">
<h3>Multi-line Text Lists</h3>
<div class="example-grid">
<div class="example-item">
<h4>Two Lines</h4>
<ui-list-container elevation="md" spacing="sm" [rounded]="true">
@for (item of twoLineItems; track item.primary) {
<ui-list-item
[data]="item"
size="md"
lines="two"
variant="text"
/>
}
</ui-list-container>
</div>
<div class="example-item">
<h4>Three Lines</h4>
<ui-list-container elevation="md" spacing="sm" [rounded]="true">
@for (item of threeLineItems; track item.primary) {
<ui-list-item
[data]="item"
size="lg"
lines="three"
variant="text"
/>
}
</ui-list-container>
</div>
</div>
</section>
<!-- Avatar Lists -->
<section class="example-section">
<h3>Lists with Avatars</h3>
<div class="example-grid">
<div class="example-item">
<h4>Avatar + One Line</h4>
<ui-list-container elevation="sm" spacing="xs" [rounded]="true">
@for (item of avatarItems; track item.primary) {
<ui-list-item
[data]="item"
size="md"
lines="one"
variant="avatar"
>
<button slot="trailing" class="action-button">
<i class="fas fa-ellipsis-v"></i>
</button>
</ui-list-item>
}
</ui-list-container>
</div>
<div class="example-item">
<h4>Avatar + Two Lines</h4>
<ui-list-container elevation="sm" spacing="xs" [rounded]="true">
@for (item of avatarTwoLineItems; track item.primary) {
<ui-list-item
[data]="item"
size="lg"
lines="two"
variant="avatar"
>
<span slot="trailing" class="timestamp">2h</span>
</ui-list-item>
}
</ui-list-container>
</div>
</div>
</section>
<!-- Media Lists -->
<section class="example-section">
<h3>Lists with Media</h3>
<div class="example-grid">
<div class="example-item">
<h4>Media + Two Lines</h4>
<ui-list-container elevation="md" spacing="sm" [rounded]="true">
@for (item of mediaItems; track item.primary) {
<ui-list-item
[data]="item"
size="lg"
lines="two"
variant="media"
>
<button slot="trailing" class="play-button">
<i class="fas fa-play"></i>
</button>
</ui-list-item>
}
</ui-list-container>
</div>
<div class="example-item">
<h4>Media + Three Lines</h4>
<ui-list-container elevation="md" spacing="sm" [rounded]="true">
@for (item of mediaThreeLineItems; track item.primary) {
<ui-list-item
[data]="item"
size="lg"
lines="three"
variant="media"
>
<div slot="trailing" class="rating">
<i class="fas fa-star"></i>
<span>4.8</span>
</div>
</ui-list-item>
}
</ui-list-container>
</div>
</div>
</section>
<!-- Scrollable List -->
<section class="example-section">
<h3>Scrollable List</h3>
<div class="example-single">
<ui-list-container
elevation="lg"
spacing="xs"
[scrollable]="true"
maxHeight="300px"
[rounded]="true"
>
@for (item of longList; track item.primary) {
<ui-list-item
[data]="item"
size="md"
lines="two"
variant="avatar"
[divider]="true"
>
@if (item.selected) {
<i slot="trailing" class="fas fa-check text-success"></i>
}
</ui-list-item>
}
</ui-list-container>
</div>
</section>
<!-- Interactive States -->
<section class="example-section">
<h3>Interactive States</h3>
<div class="example-single">
<ui-list-container elevation="sm" spacing="sm" [rounded]="true">
@for (item of interactiveItems; track item.primary) {
<ui-list-item
[data]="item"
size="md"
lines="two"
variant="avatar"
>
@if (item.selected) {
<i slot="trailing" class="fas fa-check text-success"></i>
} @else {
<button slot="trailing" class="select-button">Select</button>
}
</ui-list-item>
}
</ui-list-container>
</div>
</section>
</div>
`,
styleUrls: ['./list-examples.component.scss']
})
export class ListExamplesComponent {
basicTextItems: ListItemData[] = [
{ primary: 'Inbox' },
{ primary: 'Starred' },
{ primary: 'Sent' },
{ primary: 'Drafts' },
{ primary: 'Archive' }
];
twoLineItems: ListItemData[] = [
{
primary: 'Meeting with Design Team',
secondary: 'Discuss new component library architecture and implementation'
},
{
primary: 'Code Review - Authentication',
secondary: 'Review pull request #247 for OAuth integration updates'
},
{
primary: 'Performance Optimization',
secondary: 'Analyze bundle size and implement lazy loading strategies'
}
];
threeLineItems: ListItemData[] = [
{
primary: 'Angular 19 Migration',
secondary: 'Upgrade project to latest Angular version with new control flow',
tertiary: 'Estimated completion: Next Sprint'
},
{
primary: 'Design System Documentation',
secondary: 'Create comprehensive documentation for all components',
tertiary: 'Priority: High - Needed for team onboarding'
}
];
avatarItems: ListItemData[] = [
{
primary: 'John Doe',
avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=john',
avatarAlt: 'John Doe avatar'
},
{
primary: 'Jane Smith',
avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=jane',
avatarAlt: 'Jane Smith avatar'
},
{
primary: 'Mike Johnson',
avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=mike',
avatarAlt: 'Mike Johnson avatar'
}
];
avatarTwoLineItems: ListItemData[] = [
{
primary: 'Sarah Wilson',
secondary: 'Hey! How\'s the new component library coming along?',
avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=sarah',
avatarAlt: 'Sarah Wilson avatar'
},
{
primary: 'Alex Chen',
secondary: 'The design tokens look great. Ready for review.',
avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=alex',
avatarAlt: 'Alex Chen avatar'
}
];
mediaItems: ListItemData[] = [
{
primary: 'Angular Fundamentals',
secondary: 'Complete guide to modern Angular development',
mediaSrc: 'https://picsum.photos/80/80?random=1',
mediaAlt: 'Angular course thumbnail'
},
{
primary: 'TypeScript Deep Dive',
secondary: 'Advanced TypeScript patterns and best practices',
mediaSrc: 'https://picsum.photos/80/80?random=2',
mediaAlt: 'TypeScript course thumbnail'
}
];
mediaThreeLineItems: ListItemData[] = [
{
primary: 'Design Systems at Scale',
secondary: 'Building maintainable design systems for large organizations',
tertiary: 'Duration: 2h 30m • Updated: Today',
mediaSrc: 'https://picsum.photos/80/80?random=3',
mediaAlt: 'Design systems course'
},
{
primary: 'Component Architecture',
secondary: 'Scalable component patterns in modern web applications',
tertiary: 'Duration: 1h 45m • Updated: Yesterday',
mediaSrc: 'https://picsum.photos/80/80?random=4',
mediaAlt: 'Component architecture course'
}
];
longList: ListItemData[] = Array.from({ length: 20 }, (_, i) => ({
primary: `Contact ${i + 1}`,
secondary: `contact${i + 1}@example.com`,
avatarSrc: `https://api.dicebear.com/7.x/avataaars/svg?seed=contact${i}`,
avatarAlt: `Contact ${i + 1} avatar`,
selected: i === 3 || i === 7 || i === 12
}));
interactiveItems: ListItemData[] = [
{
primary: 'Enable Notifications',
secondary: 'Get notified about important updates',
avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=notify',
selected: false
},
{
primary: 'Dark Mode',
secondary: 'Switch to dark theme',
avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=dark',
selected: true
},
{
primary: 'Auto-save',
secondary: 'Automatically save your work',
avatarSrc: 'https://api.dicebear.com/7.x/avataaars/svg?seed=save',
selected: false,
disabled: true
}
];
}

View File

@@ -0,0 +1,360 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
// Tokens available globally via main application styles
// ==========================================================================
// LIST ITEM COMPONENT
// ==========================================================================
// Comprehensive list item component with multiple variants and sizes
// Uses semantic design tokens for consistent styling across the design system
// ==========================================================================
.list-item {
display: flex;
align-items: stretch;
width: 100%;
box-sizing: border-box;
position: relative;
user-select: none;
background-color: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
// Interactive states
&--interactive {
cursor: pointer;
transition: background-color 0.15s ease-in-out;
&:hover:not(.list-item--disabled) {
background-color: $semantic-color-surface-interactive;
}
&:focus-within:not(.list-item--disabled) {
outline: 2px solid $semantic-color-border-focus;
outline-offset: -2px;
}
}
// States
&--selected {
background-color: $semantic-color-container-primary;
color: $semantic-color-on-container-primary;
}
&--disabled {
opacity: 0.6;
cursor: not-allowed;
color: $semantic-color-text-disabled;
}
&--divider {
border-bottom: 1px solid $semantic-color-border-secondary;
}
// ==========================================================================
// SIZE VARIANTS
// ==========================================================================
&--sm {
min-height: 40px;
&.list-item--one-line { height: 40px; }
&.list-item--two-line { min-height: 56px; }
&.list-item--three-line { min-height: 72px; }
}
&--md {
min-height: 48px;
&.list-item--one-line { height: 48px; }
&.list-item--two-line { min-height: 64px; }
&.list-item--three-line { min-height: 80px; }
}
&--lg {
min-height: 56px;
&.list-item--one-line { height: 56px; }
&.list-item--two-line { min-height: 72px; }
&.list-item--three-line { min-height: 88px; }
}
}
// ==========================================================================
// LAYOUT ELEMENTS
// ==========================================================================
.list-item__avatar,
.list-item__media,
.list-item__icon {
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.list-item__avatar {
.list-item--sm & {
width: 32px;
padding: $semantic-spacing-component-sm;
}
.list-item--md & {
width: 40px;
padding: $semantic-spacing-component-md;
}
.list-item--lg & {
width: 48px;
padding: $semantic-spacing-component-lg;
}
}
.list-item__avatar-image {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
.list-item--sm & {
width: 24px;
height: 24px;
}
.list-item--md & {
width: 32px;
height: 32px;
}
.list-item--lg & {
width: 40px;
height: 40px;
}
}
.list-item__media {
.list-item--sm & {
width: 48px;
padding: $semantic-spacing-component-sm;
}
.list-item--md & {
width: 56px;
padding: $semantic-spacing-component-md;
}
.list-item--lg & {
width: 64px;
padding: $semantic-spacing-component-lg;
}
}
.list-item__media-image {
width: 100%;
height: auto;
border-radius: $semantic-border-radius-sm;
object-fit: cover;
.list-item--sm & {
width: 40px;
height: 40px;
}
.list-item--md & {
width: 48px;
height: 48px;
}
.list-item--lg & {
width: 56px;
height: 56px;
}
}
.list-item__icon {
.list-item--sm & {
width: 32px;
padding: $semantic-spacing-component-sm;
}
.list-item--md & {
width: 40px;
padding: $semantic-spacing-component-md;
}
.list-item--lg & {
width: 48px;
padding: $semantic-spacing-component-lg;
}
i {
color: $semantic-color-text-secondary;
.list-item--sm & { font-size: 16px; }
.list-item--md & { font-size: 20px; }
.list-item--lg & { font-size: 24px; }
}
}
.list-item__content {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
justify-content: center;
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
gap: $semantic-spacing-micro-tight;
.list-item--sm & {
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
}
.list-item--lg & {
padding: $semantic-spacing-component-md $semantic-spacing-component-lg;
}
}
.list-item__primary {
font-size: 1rem;
font-weight: 500;
line-height: 1.25;
color: inherit;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.list-item--sm & {
font-size: 0.875rem;
}
.list-item--lg & {
font-size: 1.125rem;
}
}
.list-item__secondary {
font-size: 0.875rem;
font-weight: 400;
line-height: 1.25;
color: $semantic-color-text-secondary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.list-item--sm & {
font-size: 0.75rem;
}
.list-item--lg & {
font-size: 1rem;
}
}
.list-item__tertiary {
font-size: 0.75rem;
font-weight: 400;
line-height: 1.25;
color: $semantic-color-text-tertiary;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.list-item--sm & {
font-size: 0.6875rem;
}
.list-item--lg & {
font-size: 0.875rem;
}
}
.list-item__trailing {
flex-shrink: 0;
display: flex;
align-items: center;
padding-right: $semantic-spacing-component-md;
.list-item--sm & {
padding-right: $semantic-spacing-component-sm;
}
.list-item--lg & {
padding-right: $semantic-spacing-component-lg;
}
&:empty {
display: none;
}
}
// ==========================================================================
// LINE VARIANTS - Content layout adjustments
// ==========================================================================
.list-item--one-line {
.list-item__content {
justify-content: center;
}
}
.list-item--two-line {
.list-item__content {
justify-content: center;
gap: $semantic-spacing-micro-tight;
}
}
.list-item--three-line {
.list-item__content {
justify-content: flex-start;
padding-top: $semantic-spacing-component-sm;
padding-bottom: $semantic-spacing-component-sm;
gap: $semantic-spacing-micro-tight;
.list-item--sm & {
padding-top: $semantic-spacing-component-xs;
padding-bottom: $semantic-spacing-component-xs;
}
.list-item--lg & {
padding-top: $semantic-spacing-component-md;
padding-bottom: $semantic-spacing-component-md;
}
}
}
// ==========================================================================
// VARIANT SPECIFIC STYLES
// ==========================================================================
.list-item--avatar {
.list-item__content {
padding-left: $semantic-spacing-component-xs;
}
}
.list-item--media {
.list-item__content {
padding-left: $semantic-spacing-component-xs;
}
}
// ==========================================================================
// RESPONSIVE ADJUSTMENTS
// ==========================================================================
@media (max-width: 768px) {
.list-item {
&--lg {
min-height: 48px;
&.list-item--one-line { height: 48px; }
&.list-item--two-line { min-height: 64px; }
&.list-item--three-line { min-height: 80px; }
}
}
.list-item__content {
padding-left: $semantic-spacing-component-sm;
padding-right: $semantic-spacing-component-sm;
}
}

View File

@@ -0,0 +1,109 @@
import { Component, Input, ChangeDetectionStrategy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
export type ListItemSize = 'sm' | 'md' | 'lg';
export type ListItemLines = 'one' | 'two' | 'three';
export type ListItemVariant = 'text' | 'avatar' | 'media';
export interface ListItemData {
primary: string;
secondary?: string;
tertiary?: string;
avatarSrc?: string;
avatarAlt?: string;
mediaSrc?: string;
mediaAlt?: string;
icon?: string;
disabled?: boolean;
selected?: boolean;
}
@Component({
selector: 'ui-list-item',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
template: `
<div
class="list-item"
[class]="getItemClasses()"
[attr.aria-disabled]="data.disabled"
[attr.aria-selected]="data.selected"
role="listitem"
>
<!-- Leading content -->
@if (variant === 'avatar' && data.avatarSrc) {
<div class="list-item__avatar">
<img
[src]="data.avatarSrc"
[alt]="data.avatarAlt || ''"
class="list-item__avatar-image"
/>
</div>
}
@if (variant === 'media' && data.mediaSrc) {
<div class="list-item__media">
<img
[src]="data.mediaSrc"
[alt]="data.mediaAlt || ''"
class="list-item__media-image"
/>
</div>
}
@if (data.icon) {
<div class="list-item__icon">
<i [class]="data.icon"></i>
</div>
}
<!-- Content area -->
<div class="list-item__content">
@if (lines === 'one') {
<div class="list-item__primary">{{ data.primary }}</div>
}
@if (lines === 'two') {
<div class="list-item__primary">{{ data.primary }}</div>
<div class="list-item__secondary">{{ data.secondary }}</div>
}
@if (lines === 'three') {
<div class="list-item__primary">{{ data.primary }}</div>
<div class="list-item__secondary">{{ data.secondary }}</div>
<div class="list-item__tertiary">{{ data.tertiary }}</div>
}
</div>
<!-- Trailing content -->
<div class="list-item__trailing">
<ng-content select="[slot=trailing]"></ng-content>
</div>
</div>
`,
styleUrls: ['./list-item.component.scss']
})
export class ListItemComponent {
@Input() data!: ListItemData;
@Input() size: ListItemSize = 'md';
@Input() lines: ListItemLines = 'one';
@Input() variant: ListItemVariant = 'text';
@Input() interactive = true;
@Input() divider = false;
getItemClasses(): string {
const classes = [
`list-item--${this.size}`,
`list-item--${this.lines}-line`,
`list-item--${this.variant}`
];
if (this.interactive) classes.push('list-item--interactive');
if (this.divider) classes.push('list-item--divider');
if (this.data.disabled) classes.push('list-item--disabled');
if (this.data.selected) classes.push('list-item--selected');
return classes.join(' ');
}
}

View File

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

View File

@@ -0,0 +1,421 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
// Tokens available globally via main application styles
// ==========================================================================
// PROGRESS BAR COMPONENT STYLES
// ==========================================================================
// Modern progress bar implementation using design tokens
// Follows Material Design 3 principles with semantic token system
// Includes determinate, indeterminate, and buffer progress types
// ==========================================================================
.progress-bar-wrapper {
display: flex;
flex-direction: column;
width: 100%;
// Size variants
&.progress-bar-wrapper--sm {
gap: $semantic-spacing-component-xs;
}
&.progress-bar-wrapper--md {
gap: $semantic-spacing-component-sm;
}
&.progress-bar-wrapper--lg {
gap: $semantic-spacing-component-md;
}
// State variants
&.progress-bar-wrapper--disabled {
opacity: 0.6;
pointer-events: none;
}
}
.progress-bar {
position: relative;
display: block;
width: 100%;
background-color: $semantic-color-surface-secondary;
border-radius: $semantic-border-radius-full;
overflow: hidden;
transition: all $semantic-duration-fast $semantic-easing-standard;
// Size variants
&.progress-bar--sm {
height: 4px;
}
&.progress-bar--md {
height: 6px;
}
&.progress-bar--lg {
height: 8px;
}
// Disabled state
&.progress-bar--disabled {
background-color: $semantic-color-surface-disabled;
}
}
.progress-bar__fill {
position: absolute;
top: 0;
left: 0;
height: 100%;
border-radius: inherit;
transition: width $semantic-duration-medium $semantic-easing-standard;
// Variant colors
.progress-bar-wrapper--primary & {
background-color: $semantic-color-brand-primary;
}
.progress-bar-wrapper--secondary & {
background-color: $semantic-color-text-secondary;
}
.progress-bar-wrapper--success & {
background-color: $semantic-color-success;
}
.progress-bar-wrapper--warning & {
background-color: $semantic-color-warning;
}
.progress-bar-wrapper--danger & {
background-color: $semantic-color-danger;
}
// Disabled state
&.progress-bar__fill--disabled {
background-color: $semantic-color-surface-disabled !important;
}
// Striped effect
&.progress-bar__fill--striped {
background-image: linear-gradient(
45deg,
rgba(255, 255, 255, 0.15) 25%,
transparent 25%,
transparent 50%,
rgba(255, 255, 255, 0.15) 50%,
rgba(255, 255, 255, 0.15) 75%,
transparent 75%,
transparent
);
background-size: 1rem 1rem;
}
// Animated stripes
&.progress-bar__fill--animated {
animation: progress-bar-stripes 1s linear infinite;
}
}
// Indeterminate progress bar
.progress-bar__fill--indeterminate {
width: 100%;
&:before {
content: '';
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
animation: indeterminate 2.1s cubic-bezier(0.650, 0.815, 0.735, 0.395) infinite;
}
&:after {
content: '';
position: absolute;
background-color: inherit;
top: 0;
left: 0;
bottom: 0;
will-change: left, right;
animation: indeterminate-short 2.1s cubic-bezier(0.165, 0.840, 0.440, 1.000) infinite;
animation-delay: 1.15s;
}
}
// Buffer progress bar
.progress-bar__buffer {
position: absolute;
top: 0;
left: 0;
height: 100%;
background-color: rgba($semantic-color-text-secondary, 0.3);
border-radius: inherit;
transition: width $semantic-duration-medium $semantic-easing-standard;
&.progress-bar__buffer--disabled {
background-color: $semantic-color-surface-disabled;
}
}
.progress-bar__fill--buffer {
z-index: 1;
}
// Label and percentage
.progress-bar__label {
display: flex;
justify-content: space-between;
align-items: center;
color: $semantic-color-text-primary;
font-weight: $semantic-typography-font-weight-medium;
line-height: $semantic-typography-line-height-tight;
// Size variants
&.progress-bar__label--sm {
font-size: $semantic-typography-font-size-sm;
margin-bottom: $semantic-spacing-component-xs;
}
&.progress-bar__label--md {
font-size: $semantic-typography-font-size-md;
margin-bottom: $semantic-spacing-component-sm;
}
&.progress-bar__label--lg {
font-size: $semantic-typography-font-size-lg;
margin-bottom: $semantic-spacing-component-sm;
}
// Disabled state
&.progress-bar__label--disabled {
color: $semantic-color-text-disabled;
}
}
.progress-bar__label-text {
flex: 1;
margin-right: $semantic-spacing-component-sm;
}
.progress-bar__percentage {
font-weight: $semantic-typography-font-weight-bold;
color: $semantic-color-text-secondary;
font-variant-numeric: tabular-nums;
.progress-bar__label--disabled & {
color: $semantic-color-text-disabled;
}
}
.progress-bar__helper-text {
color: $semantic-color-text-secondary;
line-height: $semantic-typography-line-height-tight;
// Size variants
&.progress-bar__helper-text--sm {
font-size: $semantic-typography-font-size-xs;
margin-top: $semantic-spacing-component-xs;
}
&.progress-bar__helper-text--md {
font-size: $semantic-typography-font-size-sm;
margin-top: $semantic-spacing-component-xs;
}
&.progress-bar__helper-text--lg {
font-size: $semantic-typography-font-size-md;
margin-top: $semantic-spacing-component-sm;
}
// Disabled state
&.progress-bar__helper-text--disabled {
color: $semantic-color-text-disabled;
}
}
// ==========================================================================
// ANIMATIONS
// ==========================================================================
// Striped animation
@keyframes progress-bar-stripes {
0% {
background-position-x: 1rem;
}
100% {
background-position-x: 0;
}
}
// Indeterminate animations
@keyframes indeterminate {
0% {
left: -35%;
right: 100%;
}
60% {
left: 100%;
right: -90%;
}
100% {
left: 100%;
right: -90%;
}
}
@keyframes indeterminate-short {
0% {
left: -200%;
right: 100%;
}
60% {
left: 107%;
right: -8%;
}
100% {
left: 107%;
right: -8%;
}
}
// Pulse animation for focus/hover states
@keyframes progress-bar-pulse {
0% {
box-shadow: 0 0 0 0 rgba($semantic-color-brand-primary, 0.4);
}
70% {
box-shadow: 0 0 0 4px rgba($semantic-color-brand-primary, 0);
}
100% {
box-shadow: 0 0 0 0 rgba($semantic-color-brand-primary, 0);
}
}
// ==========================================================================
// HOVER AND FOCUS STATES
// ==========================================================================
.progress-bar:hover:not(.progress-bar--disabled) {
transform: scaleY(1.1);
box-shadow: $semantic-shadow-elevation-1;
}
.progress-bar:focus-visible {
outline: $semantic-border-width-2 solid $semantic-color-brand-primary;
outline-offset: 2px;
}
// ==========================================================================
// RESPONSIVE DESIGN
// ==========================================================================
@media (max-width: $semantic-sizing-breakpoint-tablet - 1) {
.progress-bar-wrapper {
// Slightly larger progress bars on mobile for better visibility
&.progress-bar-wrapper--sm {
.progress-bar--sm {
height: 5px;
}
}
&.progress-bar-wrapper--md {
.progress-bar--md {
height: 7px;
}
}
&.progress-bar-wrapper--lg {
.progress-bar--lg {
height: 9px;
}
}
}
}
// ==========================================================================
// ACCESSIBILITY ENHANCEMENTS
// ==========================================================================
// Reduced motion preference
@media (prefers-reduced-motion: reduce) {
.progress-bar__fill,
.progress-bar__buffer {
transition: none !important;
}
.progress-bar__fill--animated,
.progress-bar__fill--indeterminate:before,
.progress-bar__fill--indeterminate:after {
animation: none !important;
}
}
// High contrast mode
@media (prefers-contrast: high) {
.progress-bar {
border: $semantic-border-width-1 solid $semantic-color-text-primary;
}
.progress-bar__fill {
border: $semantic-border-width-1 solid $semantic-color-text-primary;
border-radius: 0;
}
}
// ==========================================================================
// THEME VARIANTS
// ==========================================================================
// Dark theme adjustments
@media (prefers-color-scheme: dark) {
.progress-bar {
background-color: rgba($semantic-color-surface-primary, 0.1);
}
.progress-bar__buffer {
background-color: rgba($semantic-color-text-secondary, 0.2);
}
}
// ==========================================================================
// SPECIAL STATES AND EFFECTS
// ==========================================================================
// Success completion effect
.progress-bar-wrapper--success {
.progress-bar__fill[style*="100%"] {
animation: progress-complete 0.6s $semantic-easing-spring;
}
}
@keyframes progress-complete {
0% {
transform: scaleX(1);
}
50% {
transform: scaleX(1.02);
}
100% {
transform: scaleX(1);
}
}
// Error state pulsing
.progress-bar-wrapper--danger {
.progress-bar__fill {
animation: progress-error 2s ease-in-out infinite;
}
}
@keyframes progress-error {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.8;
}
}

View File

@@ -0,0 +1,213 @@
import { Component, Input, ChangeDetectionStrategy, signal, computed, ViewChild, ElementRef } from '@angular/core';
import { CommonModule } from '@angular/common';
export type ProgressBarSize = 'sm' | 'md' | 'lg';
export type ProgressBarVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'danger';
export type ProgressBarType = 'determinate' | 'indeterminate' | 'buffer';
export type ProgressBarState = 'default' | 'disabled';
@Component({
selector: 'ui-progress-bar',
standalone: true,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [CommonModule],
template: `
<div class="progress-bar-wrapper" [class]="getWrapperClasses()" [attr.aria-label]="ariaLabel">
@if (showLabelComputed()) {
<div class="progress-bar__label" [class]="getLabelClasses()">
<span class="progress-bar__label-text">{{ label }}</span>
@if (showPercentageComputed() && type() === 'determinate') {
<span class="progress-bar__percentage">{{ Math.round(value()) }}%</span>
}
</div>
}
<div
#progressElement
class="progress-bar"
[class]="getProgressBarClasses()"
role="progressbar"
[attr.aria-valuenow]="type() === 'determinate' ? value() : null"
[attr.aria-valuemin]="type() === 'determinate' ? 0 : null"
[attr.aria-valuemax]="type() === 'determinate' ? 100 : null"
[attr.aria-valuetext]="getAriaValueText()"
[attr.aria-label]="ariaLabel || label || 'Progress'"
>
@if (type() === 'determinate') {
<div
class="progress-bar__fill progress-bar__fill--determinate"
[class]="getFillClasses()"
[style.width.%]="value()">
</div>
}
@if (type() === 'indeterminate') {
<div
class="progress-bar__fill progress-bar__fill--indeterminate"
[class]="getFillClasses()">
</div>
}
@if (type() === 'buffer') {
<div
class="progress-bar__buffer"
[class]="getBufferClasses()"
[style.width.%]="bufferValue()">
</div>
<div
class="progress-bar__fill progress-bar__fill--buffer"
[class]="getFillClasses()"
[style.width.%]="value()">
</div>
}
</div>
@if (helperTextComputed()) {
<div class="progress-bar__helper-text" [class]="getHelperTextClasses()">
{{ helperTextComputed() }}
</div>
}
</div>
`,
styleUrls: ['./progress-bar.component.scss']
})
export class ProgressBarComponent {
@ViewChild('progressElement', { static: false }) progressElement!: ElementRef<HTMLDivElement>;
// Core inputs
@Input() set progress(value: number) { this._value.set(Math.max(0, Math.min(100, value))); }
@Input() set buffer(value: number) { this._bufferValue.set(Math.max(0, Math.min(100, value))); }
@Input() label: string = '';
@Input() size: ProgressBarSize = 'md';
@Input() variant: ProgressBarVariant = 'primary';
@Input() progressType: ProgressBarType = 'determinate';
@Input() state: ProgressBarState = 'default';
// Display options
@Input() showLabel = true;
@Input() showPercentage = true;
@Input() striped = false;
@Input() animated = false;
// Accessibility
@Input() ariaLabel: string = '';
@Input() helperText: string = '';
@Input() progressId: string = `progress-${Math.random().toString(36).substr(2, 9)}`;
// Exposed Math for template
protected readonly Math = Math;
// Internal state
private _value = signal<number>(0);
private _bufferValue = signal<number>(0);
// Computed properties
protected value = computed(() => this._value());
protected bufferValue = computed(() => this._bufferValue());
protected type = computed(() => this.progressType);
protected showLabelComputed = computed(() => this.label && this.showLabel);
protected showPercentageComputed = computed(() => this.showPercentage && this.progressType === 'determinate');
protected helperTextComputed = computed(() => this.helperText);
ngOnInit(): void {
// Initialize with default values if not set
if (this._value() === 0 && this.progressType === 'determinate') {
this._value.set(0);
}
}
getAriaValueText(): string {
if (this.progressType === 'determinate') {
return `${Math.round(this.value())}% complete`;
} else if (this.progressType === 'indeterminate') {
return 'Loading...';
} else {
return `${Math.round(this.value())}% of ${Math.round(this.bufferValue())}%`;
}
}
// CSS class getters
getWrapperClasses(): string {
const classes = [
`progress-bar-wrapper--${this.size}`,
`progress-bar-wrapper--${this.variant}`,
`progress-bar-wrapper--${this.progressType}`,
`progress-bar-wrapper--${this.state}`
];
if (this.state === 'disabled') classes.push('progress-bar-wrapper--disabled');
if (this.striped) classes.push('progress-bar-wrapper--striped');
if (this.animated) classes.push('progress-bar-wrapper--animated');
return classes.join(' ');
}
getProgressBarClasses(): string {
const classes = [
`progress-bar--${this.size}`,
`progress-bar--${this.variant}`,
`progress-bar--${this.progressType}`,
`progress-bar--${this.state}`
];
if (this.state === 'disabled') classes.push('progress-bar--disabled');
return classes.join(' ');
}
getFillClasses(): string {
const classes = [`progress-bar__fill--${this.size}`];
if (this.striped) classes.push('progress-bar__fill--striped');
if (this.animated) classes.push('progress-bar__fill--animated');
if (this.state === 'disabled') classes.push('progress-bar__fill--disabled');
return classes.join(' ');
}
getBufferClasses(): string {
const classes = [`progress-bar__buffer--${this.size}`];
if (this.state === 'disabled') classes.push('progress-bar__buffer--disabled');
return classes.join(' ');
}
getLabelClasses(): string {
const classes = [`progress-bar__label--${this.size}`];
if (this.state === 'disabled') classes.push('progress-bar__label--disabled');
return classes.join(' ');
}
getHelperTextClasses(): string {
const classes = [`progress-bar__helper-text--${this.size}`];
if (this.state === 'disabled') classes.push('progress-bar__helper-text--disabled');
return classes.join(' ');
}
// Public methods for programmatic control
setProgress(value: number): void {
this._value.set(Math.max(0, Math.min(100, value)));
}
setBuffer(value: number): void {
this._bufferValue.set(Math.max(0, Math.min(100, value)));
}
reset(): void {
this._value.set(0);
this._bufferValue.set(0);
}
complete(): void {
this._value.set(100);
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,400 @@
@use 'ui-design-system/src/styles/semantic' as *;
.ui-table-actions {
display: flex;
align-items: center;
gap: 0.25rem;
position: relative;
// Size variants
&--small {
gap: 0.125rem;
}
&--medium {
gap: 0.25rem;
}
&--large {
gap: 0.375rem;
}
// Layout modifiers
&--compact {
gap: 0.125rem;
.ui-table-action {
min-width: auto;
}
}
&--with-labels {
gap: 0.5rem;
}
}
.ui-table-action {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.375rem;
border: 1px solid transparent;
border-radius: 0.25rem;
background: transparent;
color: hsl(287, 12%, 47%);
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 0.875rem;
font-weight: 500;
line-height: 1;
cursor: pointer;
transition: all 150ms cubic-bezier(0, 0, 0.2, 1);
min-width: 2rem;
min-height: 2rem;
position: relative;
user-select: none;
&:hover:not(:disabled) {
background: hsl(289, 14%, 96%);
color: hsl(279, 14%, 11%);
transform: scale(1.05);
}
&:active:not(:disabled) {
transform: scale(0.98);
}
&:focus-visible {
outline: 2px solid hsl(258, 100%, 47%);
outline-offset: 1px;
}
// Action variants
&--edit {
color: hsl(207, 90%, 45%);
&:hover:not(:disabled) {
background: hsl(207, 90%, 95%);
color: hsl(207, 90%, 35%);
border-color: hsl(207, 90%, 80%);
}
}
&--delete {
color: hsl(0, 84%, 45%);
&:hover:not(:disabled) {
background: hsl(0, 84%, 95%);
color: hsl(0, 84%, 35%);
border-color: hsl(0, 84%, 80%);
}
}
&--view {
color: hsl(142, 76%, 45%);
&:hover:not(:disabled) {
background: hsl(142, 76%, 95%);
color: hsl(142, 76%, 35%);
border-color: hsl(142, 76%, 80%);
}
}
&--download {
color: hsl(45, 93%, 45%);
&:hover:not(:disabled) {
background: hsl(45, 93%, 95%);
color: hsl(45, 93%, 35%);
border-color: hsl(45, 93%, 80%);
}
}
&--custom {
color: hsl(258, 100%, 47%);
&:hover:not(:disabled) {
background: hsl(263, 100%, 95%);
color: hsl(258, 100%, 35%);
border-color: hsl(263, 100%, 80%);
}
}
// Size variants
.ui-table-actions--small & {
padding: 0.25rem;
min-width: 1.5rem;
min-height: 1.5rem;
font-size: 0.75rem;
.ui-table-action__icon {
font-size: 0.75rem;
}
}
.ui-table-actions--large & {
padding: 0.5rem;
min-width: 2.5rem;
min-height: 2.5rem;
font-size: 1rem;
.ui-table-action__icon {
font-size: 1rem;
}
}
// States
&--disabled {
opacity: 0.4;
cursor: not-allowed;
pointer-events: none;
}
&--active {
background: hsl(289, 14%, 90%);
color: hsl(279, 14%, 11%);
border-color: hsl(289, 14%, 80%);
}
// More button specific styles
&--more {
color: hsl(287, 12%, 47%);
&:hover:not(:disabled) {
background: hsl(289, 14%, 90%);
color: hsl(279, 14%, 11%);
}
&.ui-table-action--active {
background: hsl(258, 100%, 95%);
color: hsl(258, 100%, 47%);
border-color: hsl(258, 100%, 80%);
}
}
}
.ui-table-action__icon {
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 0.875rem;
line-height: 1;
flex-shrink: 0;
// Icon-only adjustments
.ui-table-actions:not(.ui-table-actions--with-labels) & {
margin: 0;
}
}
.ui-table-action__label {
font-size: 0.8125rem;
white-space: nowrap;
flex-shrink: 0;
// Hide labels by default, show when --with-labels
.ui-table-actions:not(.ui-table-actions--with-labels) & {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
}
// Dropdown styles
.ui-table-actions__dropdown {
position: relative;
display: inline-flex;
}
.ui-table-actions__dropdown-menu {
position: absolute;
top: calc(100% + 0.25rem);
right: 0;
min-width: 10rem;
background: hsl(286, 20%, 99%);
border: 1px solid hsl(289, 14%, 80%);
border-radius: 0.375rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -2px rgba(0, 0, 0, 0.1);
padding: 0.25rem;
z-index: 50;
animation: dropdown-appear 150ms cubic-bezier(0, 0, 0.2, 1);
// Ensure dropdown is on top
&::before {
content: '';
position: absolute;
top: -0.25rem;
right: 0.75rem;
width: 0;
height: 0;
border-left: 0.25rem solid transparent;
border-right: 0.25rem solid transparent;
border-bottom: 0.25rem solid hsl(289, 14%, 80%);
}
&::after {
content: '';
position: absolute;
top: -0.1875rem;
right: 0.8125rem;
width: 0;
height: 0;
border-left: 0.1875rem solid transparent;
border-right: 0.1875rem solid transparent;
border-bottom: 0.1875rem solid hsl(286, 20%, 99%);
}
}
.ui-table-actions__dropdown-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
border: none;
border-radius: 0.25rem;
background: transparent;
color: hsl(279, 14%, 11%);
font-family: inherit;
font-size: 0.875rem;
font-weight: 500;
text-align: left;
cursor: pointer;
transition: background-color 150ms ease;
&:hover:not(:disabled) {
background: hsl(289, 14%, 96%);
}
&:focus-visible {
outline: 2px solid hsl(258, 100%, 47%);
outline-offset: -2px;
}
&:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.ui-table-action__icon {
font-size: 0.875rem;
}
.ui-table-action__label {
position: static;
width: auto;
height: auto;
padding: 0;
margin: 0;
overflow: visible;
clip: auto;
white-space: normal;
border: none;
}
}
// Animation for dropdown
@keyframes dropdown-appear {
0% {
opacity: 0;
transform: translateY(-0.25rem) scale(0.98);
}
100% {
opacity: 1;
transform: translateY(0) scale(1);
}
}
// Tooltip support (when labels are hidden)
.ui-table-actions:not(.ui-table-actions--with-labels) {
.ui-table-action {
position: relative;
&[title]:hover::after {
content: attr(title);
position: absolute;
bottom: calc(100% + 0.5rem);
left: 50%;
transform: translateX(-50%);
padding: 0.375rem 0.5rem;
background: hsl(279, 14%, 11%);
color: white;
font-size: 0.75rem;
white-space: nowrap;
border-radius: 0.25rem;
z-index: 100;
animation: tooltip-appear 150ms ease-out;
}
&[title]:hover::before {
content: '';
position: absolute;
bottom: calc(100% + 0.125rem);
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 0.25rem solid transparent;
border-right: 0.25rem solid transparent;
border-top: 0.25rem solid hsl(279, 14%, 11%);
z-index: 100;
}
}
}
@keyframes tooltip-appear {
0% {
opacity: 0;
transform: translateX(-50%) translateY(0.125rem);
}
100% {
opacity: 1;
transform: translateX(-50%) translateY(0);
}
}
// Responsive design
@media (max-width: 640px) {
.ui-table-actions {
&--with-labels .ui-table-action__label {
display: none;
}
&--with-labels {
gap: 0.25rem;
}
}
}
// High contrast mode
@media (prefers-contrast: high) {
.ui-table-action {
border: 1px solid;
&:hover:not(:disabled) {
border-width: 2px;
}
}
.ui-table-actions__dropdown-menu {
border-width: 2px;
}
}
// Reduced motion
@media (prefers-reduced-motion: reduce) {
.ui-table-action,
.ui-table-actions__dropdown-menu,
.ui-table-actions__dropdown-item {
transition: none;
}
.ui-table-actions__dropdown-menu {
animation: none;
}
}

View File

@@ -0,0 +1,177 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
export type ActionVariant = 'edit' | 'delete' | 'view' | 'download' | 'custom';
export type ActionSize = 'small' | 'medium' | 'large';
export interface TableAction {
id: string;
label: string;
variant: ActionVariant;
icon?: string;
disabled?: boolean;
hidden?: boolean;
tooltip?: string;
confirmMessage?: string;
}
@Component({
selector: 'ui-table-actions',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div [class]="containerClasses">
@for (action of visibleActions; track action.id) {
<button
[class]="getActionClasses(action)"
[disabled]="action.disabled"
[title]="action.tooltip || action.label"
[attr.aria-label]="action.label"
(click)="handleActionClick(action, $event)"
type="button"
>
@if (action.icon) {
<span class="ui-table-action__icon" [innerHTML]="action.icon"></span>
}
@if (showLabels) {
<span class="ui-table-action__label">{{ action.label }}</span>
}
</button>
}
@if (hasMoreActions) {
<div class="ui-table-actions__dropdown" [class.ui-table-actions__dropdown--open]="dropdownOpen">
<button
class="ui-table-action ui-table-action--more"
[class]="getMoreButtonClasses()"
(click)="toggleDropdown()"
[attr.aria-expanded]="dropdownOpen"
[attr.aria-haspopup]="true"
type="button"
>
<span class="ui-table-action__icon">⋯</span>
@if (showLabels) {
<span class="ui-table-action__label">More</span>
}
</button>
@if (dropdownOpen) {
<div class="ui-table-actions__dropdown-menu" role="menu">
@for (action of hiddenActions; track action.id) {
<button
class="ui-table-actions__dropdown-item"
[disabled]="action.disabled"
[attr.aria-label]="action.label"
(click)="handleActionClick(action, $event)"
role="menuitem"
type="button"
>
@if (action.icon) {
<span class="ui-table-action__icon" [innerHTML]="action.icon"></span>
}
<span class="ui-table-action__label">{{ action.label }}</span>
</button>
}
</div>
}
</div>
}
</div>
`,
styleUrl: './table-actions.component.scss'
})
export class TableActionsComponent {
@Input() actions: TableAction[] = [];
@Input() size: ActionSize = 'medium';
@Input() maxVisibleActions: number = 3;
@Input() showLabels: boolean = false;
@Input() compact: boolean = false;
@Input() data: any = null; // Row data passed to action handlers
@Input() index: number = -1; // Row index
@Input() class: string = '';
@Output() actionClick = new EventEmitter<{action: TableAction, data: any, index: number, event: Event}>();
dropdownOpen = false;
get visibleActions(): TableAction[] {
const filtered = this.actions.filter(action => !action.hidden);
return filtered.slice(0, this.maxVisibleActions);
}
get hiddenActions(): TableAction[] {
const filtered = this.actions.filter(action => !action.hidden);
return filtered.slice(this.maxVisibleActions);
}
get hasMoreActions(): boolean {
return this.hiddenActions.length > 0;
}
get containerClasses(): string {
return [
'ui-table-actions',
`ui-table-actions--${this.size}`,
this.compact ? 'ui-table-actions--compact' : '',
this.showLabels ? 'ui-table-actions--with-labels' : '',
this.class
].filter(Boolean).join(' ');
}
getActionClasses(action: TableAction): string {
return [
'ui-table-action',
`ui-table-action--${action.variant}`,
action.disabled ? 'ui-table-action--disabled' : '',
].filter(Boolean).join(' ');
}
getMoreButtonClasses(): string {
return [
`ui-table-action--${this.size}`,
this.dropdownOpen ? 'ui-table-action--active' : ''
].filter(Boolean).join(' ');
}
handleActionClick(action: TableAction, event: Event): void {
if (action.disabled) {
return;
}
// Close dropdown if it's open
this.dropdownOpen = false;
// Handle confirmation if required
if (action.confirmMessage) {
if (!confirm(action.confirmMessage)) {
return;
}
}
this.actionClick.emit({
action,
data: this.data,
index: this.index,
event
});
}
toggleDropdown(): void {
this.dropdownOpen = !this.dropdownOpen;
if (this.dropdownOpen) {
// Close dropdown when clicking outside
setTimeout(() => {
const handleClickOutside = (event: Event) => {
const target = event.target as Element;
if (!target.closest('.ui-table-actions__dropdown')) {
this.dropdownOpen = false;
document.removeEventListener('click', handleClickOutside);
}
};
document.addEventListener('click', handleClickOutside);
});
}
}
}

View File

@@ -0,0 +1,441 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
// Modern table component using design tokens
.ui-table-container {
position: relative;
width: 100%;
overflow: auto;
background: hsl(286, 20%, 99%); // Surface color
border-radius: 0.5rem;
box-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px -1px rgba(0, 0, 0, 0.1);
// Sticky header container
&--sticky-header {
max-height: 400px;
.ui-table__header {
position: sticky;
top: 0;
z-index: 10;
background: hsl(285, 9%, 87%); // Surface dim
}
}
// Max height container
&--max-height {
max-height: var(--ui-table-max-height, 500px);
}
// Loading state
&--loading {
pointer-events: none;
}
}
.ui-table {
width: 100%;
border-collapse: separate;
border-spacing: 0;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
position: relative;
// Subtle noise texture for modern brutalist feel
&::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="100%" height="100%"><filter id="noise"><feTurbulence type="fractalNoise" baseFrequency="0.9" numOctaves="1" stitchTiles="stitch"/><feColorMatrix type="saturate" values="0"/></filter><rect width="100%" height="100%" filter="url(%23noise)" opacity="0.02"/></svg>');
pointer-events: none;
mix-blend-mode: overlay;
border-radius: inherit;
z-index: 1;
}
// Size variants
&--compact {
.ui-table__header-cell,
.ui-table__body-cell {
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
}
}
&--default {
.ui-table__header-cell,
.ui-table__body-cell {
padding: 0.75rem 1rem;
font-size: 0.875rem;
}
}
&--comfortable {
.ui-table__header-cell,
.ui-table__body-cell {
padding: 1rem 1.25rem;
font-size: 1rem;
}
}
// Style variants
&--bordered {
border: 1px solid hsl(289, 14%, 80%);
.ui-table__header-cell,
.ui-table__body-cell {
border-right: 1px solid hsl(289, 14%, 80%);
&:last-child {
border-right: none;
}
}
.ui-table__body-row {
border-bottom: 1px solid hsl(289, 14%, 80%);
}
}
&--minimal {
.ui-table__header {
border-bottom: 2px solid hsl(258, 100%, 47%);
}
.ui-table__body-row {
border-bottom: none;
&:not(:last-child) {
border-bottom: 1px solid hsl(289, 14%, 90%);
}
}
}
// Hover effects
&--hoverable {
.ui-table__body-row {
transition: background-color 150ms cubic-bezier(0, 0, 0.2, 1);
cursor: pointer;
&:hover {
background: hsl(289, 14%, 96%);
position: relative;
z-index: 2;
}
}
}
// Selection
&--selectable {
.ui-table__body-row {
cursor: pointer;
&--selected {
background: hsl(263, 100%, 95%);
border-left: 3px solid hsl(258, 100%, 47%);
}
}
}
}
// Header styles
.ui-table__header {
background: hsl(285, 9%, 87%);
border-bottom: 2px solid hsl(287, 12%, 59%);
position: relative;
z-index: 5;
}
.ui-table__header-row {
// No additional styles needed
}
.ui-table__header-cell {
text-align: left;
font-weight: 700;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(279, 14%, 11%);
position: relative;
white-space: nowrap;
user-select: none;
// Alignment variants
&--left {
text-align: left;
}
&--center {
text-align: center;
}
&--right {
text-align: right;
}
// Sortable headers
&--sortable {
cursor: pointer;
transition: background-color 150ms ease;
&:hover {
background: hsl(285, 9%, 82%);
}
&--sorted {
color: hsl(258, 100%, 47%);
background: hsl(263, 100%, 95%);
}
}
// Sticky columns
&--sticky {
position: sticky;
left: 0;
background: inherit;
z-index: 6;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.1);
}
}
.ui-table__header-content {
display: flex;
align-items: center;
gap: 0.5rem;
min-height: 1.5rem;
}
.ui-table__header-text {
flex: 1;
}
.ui-table__sort-indicator {
font-size: 0.875rem;
color: hsl(258, 100%, 47%);
font-weight: bold;
opacity: 0.8;
&[data-direction="asc"] {
opacity: 1;
}
&[data-direction="desc"] {
opacity: 1;
}
}
// Body styles
.ui-table__body {
position: relative;
z-index: 2;
}
.ui-table__body-row {
transition: all 150ms cubic-bezier(0, 0, 0.2, 1);
border-bottom: 1px solid hsl(289, 14%, 90%);
&:last-child {
border-bottom: none;
}
// Striped variant
&--striped {
background: hsl(289, 14%, 98%);
}
// Selection state
&--selected {
background: hsl(263, 100%, 95%);
box-shadow: inset 3px 0 0 hsl(258, 100%, 47%);
}
}
.ui-table__body-cell {
color: hsl(279, 14%, 11%);
font-weight: 400;
line-height: 1.5;
position: relative;
// Alignment variants
&--left {
text-align: left;
}
&--center {
text-align: center;
}
&--right {
text-align: right;
}
// Sticky columns
&--sticky {
position: sticky;
left: 0;
background: inherit;
z-index: 3;
box-shadow: 2px 0 4px rgba(0, 0, 0, 0.05);
.ui-table__body-row:hover & {
background: hsl(289, 14%, 96%);
}
.ui-table__body-row--selected & {
background: hsl(263, 100%, 95%);
}
.ui-table__body-row--striped & {
background: hsl(289, 14%, 98%);
}
}
}
.ui-table__cell-content {
display: block;
width: 100%;
word-break: break-word;
}
// Empty state
.ui-table__empty-row {
background: transparent;
&:hover {
background: transparent !important;
}
}
.ui-table__empty-cell {
padding: 3rem 1rem;
text-align: center;
border-bottom: none;
}
.ui-table__empty-content {
display: flex;
flex-direction: column;
align-items: center;
gap: 1rem;
}
.ui-table__empty-text {
color: hsl(287, 12%, 47%);
font-size: 1rem;
font-weight: 500;
}
// Loading overlay
.ui-table__loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.9);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1rem;
z-index: 20;
backdrop-filter: blur(2px);
}
.ui-table__loading-spinner {
width: 2rem;
height: 2rem;
border: 3px solid hsl(289, 14%, 80%);
border-top: 3px solid hsl(258, 100%, 47%);
border-radius: 50%;
animation: spin 1s linear infinite;
}
.ui-table__loading-text {
color: hsl(287, 12%, 47%);
font-size: 0.875rem;
font-weight: 500;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// Responsive design
@media (max-width: 768px) {
.ui-table-container {
border-radius: 0;
margin: 0 -1rem;
}
.ui-table {
&--compact {
.ui-table__header-cell,
.ui-table__body-cell {
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
}
}
&--default {
.ui-table__header-cell,
.ui-table__body-cell {
padding: 0.5rem 0.625rem;
font-size: 0.8125rem;
}
}
}
.ui-table__header-cell,
.ui-table__body-cell {
&:first-child {
padding-left: 1rem;
}
&:last-child {
padding-right: 1rem;
}
}
}
// Focus management
.ui-table__header-cell--sortable:focus-visible {
outline: 2px solid hsl(258, 100%, 47%);
outline-offset: -2px;
}
// Print styles
@media print {
.ui-table-container {
box-shadow: none;
border: 1px solid #000;
}
.ui-table {
&::before {
display: none;
}
}
.ui-table__loading-overlay {
display: none;
}
}
// High contrast mode
@media (prefers-contrast: high) {
.ui-table {
border: 2px solid;
}
.ui-table__header-cell,
.ui-table__body-cell {
border: 1px solid;
}
.ui-table__body-row:hover {
background: highlight;
color: highlighttext;
}
}

View File

@@ -0,0 +1,246 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ContentChild, TemplateRef } from '@angular/core';
import { CommonModule } from '@angular/common';
export type TableVariant = 'default' | 'striped' | 'bordered' | 'minimal';
export type TableSize = 'compact' | 'default' | 'comfortable';
export type TableColumnAlign = 'left' | 'center' | 'right';
export interface TableColumn {
key: string;
label: string;
sortable?: boolean;
align?: TableColumnAlign;
width?: string;
sticky?: boolean;
}
export interface TableSortEvent {
column: string;
direction: 'asc' | 'desc' | null;
}
@Component({
selector: 'ui-table',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<div [class]="tableContainerClasses">
<table [class]="tableClasses">
<!-- Table Header -->
@if (showHeader && columns.length > 0) {
<thead class="ui-table__header">
<tr class="ui-table__header-row">
@for (column of columns; track column.key) {
<th
[class]="getHeaderCellClasses(column)"
[style.width]="column.width"
[attr.aria-sort]="getSortAttribute(column.key)"
(click)="handleSort(column)"
>
<div class="ui-table__header-content">
<span class="ui-table__header-text">{{ column.label }}</span>
@if (column.sortable && sortColumn === column.key) {
<span class="ui-table__sort-indicator" [attr.data-direction]="sortDirection">
@if (sortDirection === 'asc') {
} @else if (sortDirection === 'desc') {
}
</span>
}
</div>
</th>
}
</tr>
</thead>
}
<!-- Table Body -->
<tbody class="ui-table__body">
@if (data.length === 0 && showEmptyState) {
<tr class="ui-table__empty-row">
<td [attr.colspan]="columns.length" class="ui-table__empty-cell">
<div class="ui-table__empty-content">
@if (emptyTemplate) {
<ng-container [ngTemplateOutlet]="emptyTemplate"></ng-container>
} @else {
<div class="ui-table__empty-text">{{ emptyMessage }}</div>
}
</div>
</td>
</tr>
} @else {
@for (row of data; track trackByFn ? trackByFn($index, row) : $index; let rowIndex = $index) {
<tr
[class]="getRowClasses(row, rowIndex)"
(click)="handleRowClick(row, rowIndex)"
[attr.aria-rowindex]="rowIndex + 1"
>
@for (column of columns; track column.key) {
<td [class]="getBodyCellClasses(column, row)">
@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-table__cell-content">
{{ getCellValue(row, column.key) }}
</span>
}
</td>
}
</tr>
}
}
</tbody>
</table>
<!-- Loading Overlay -->
@if (loading) {
<div class="ui-table__loading-overlay">
<div class="ui-table__loading-spinner"></div>
<div class="ui-table__loading-text">{{ loadingMessage }}</div>
</div>
}
</div>
`,
styleUrl: './table.component.scss'
})
export class TableComponent {
@Input() data: any[] = [];
@Input() columns: TableColumn[] = [];
@Input() variant: TableVariant = 'default';
@Input() size: TableSize = 'default';
@Input() showHeader: boolean = true;
@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() sortColumn: string = '';
@Input() sortDirection: 'asc' | 'desc' | null = null;
@Input() trackByFn?: (index: number, item: any) => any;
@Input() cellTemplates: Record<string, TemplateRef<any>> = {};
@Input() class: string = '';
@Output() rowClick = new EventEmitter<{row: any, index: number}>();
@Output() sort = new EventEmitter<TableSortEvent>();
@Output() selectionChange = new EventEmitter<any[]>();
@ContentChild('emptyTemplate') emptyTemplate?: TemplateRef<any>;
selectedRows: Set<any> = new Set();
get tableContainerClasses(): string {
return [
'ui-table-container',
this.stickyHeader ? 'ui-table-container--sticky-header' : '',
this.maxHeight ? 'ui-table-container--max-height' : '',
this.loading ? 'ui-table-container--loading' : '',
this.class
].filter(Boolean).join(' ');
}
get tableClasses(): string {
return [
'ui-table',
`ui-table--${this.variant}`,
`ui-table--${this.size}`,
this.hoverable ? 'ui-table--hoverable' : '',
this.selectable ? 'ui-table--selectable' : '',
].filter(Boolean).join(' ');
}
getHeaderCellClasses(column: TableColumn): string {
return [
'ui-table__header-cell',
`ui-table__header-cell--${column.align || 'left'}`,
column.sortable ? 'ui-table__header-cell--sortable' : '',
column.sticky ? 'ui-table__header-cell--sticky' : '',
this.sortColumn === column.key ? 'ui-table__header-cell--sorted' : ''
].filter(Boolean).join(' ');
}
getBodyCellClasses(column: TableColumn, row: any): string {
return [
'ui-table__body-cell',
`ui-table__body-cell--${column.align || 'left'}`,
column.sticky ? 'ui-table__body-cell--sticky' : ''
].filter(Boolean).join(' ');
}
getRowClasses(row: any, index: number): string {
return [
'ui-table__body-row',
this.selectedRows.has(row) ? 'ui-table__body-row--selected' : '',
index % 2 === 1 && this.variant === 'striped' ? 'ui-table__body-row--striped' : ''
].filter(Boolean).join(' ');
}
handleSort(column: TableColumn): void {
if (!column.sortable) return;
let newDirection: 'asc' | 'desc' | null;
if (this.sortColumn === column.key) {
// Toggle through: asc -> desc -> null -> asc
if (this.sortDirection === 'asc') {
newDirection = 'desc';
} else if (this.sortDirection === 'desc') {
newDirection = null;
} else {
newDirection = 'asc';
}
} else {
newDirection = 'asc';
}
this.sort.emit({
column: column.key,
direction: newDirection
});
}
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;
}
getSortAttribute(columnKey: string): string | null {
if (this.sortColumn === columnKey) {
return this.sortDirection === 'asc' ? 'ascending' : 'descending';
}
return null;
}
}

View File

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

View File

@@ -0,0 +1,331 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-timeline {
// Core Structure
display: flex;
flex-direction: column;
position: relative;
// Layout & Spacing
padding: $semantic-spacing-component-md;
// 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;
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-timeline__item {
padding-bottom: $semantic-spacing-content-line-tight;
}
.ui-timeline__marker {
width: $semantic-sizing-icon-inline;
height: $semantic-sizing-icon-inline;
}
.ui-timeline__content {
margin-left: $semantic-spacing-component-xs;
}
}
&--md {
.ui-timeline__item {
padding-bottom: $semantic-spacing-component-sm;
}
.ui-timeline__marker {
width: $semantic-sizing-icon-button;
height: $semantic-sizing-icon-button;
}
.ui-timeline__content {
margin-left: $semantic-spacing-component-sm;
}
}
&--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);
.ui-timeline__item {
padding-bottom: $semantic-spacing-component-md;
}
.ui-timeline__marker {
width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
}
.ui-timeline__content {
margin-left: $semantic-spacing-component-md;
}
}
// Timeline Item
&__item {
display: flex;
align-items: flex-start;
position: relative;
padding-bottom: $semantic-spacing-component-sm;
&:not(:last-child) {
&::after {
content: '';
position: absolute;
left: calc($semantic-sizing-icon-button / 2 - $semantic-border-width-1 / 2);
top: calc($semantic-sizing-icon-button + $semantic-spacing-content-line-tight);
width: $semantic-border-width-1;
height: calc(100% - $semantic-sizing-icon-button - $semantic-spacing-content-line-tight);
background: $semantic-color-border-secondary;
}
}
&--completed {
.ui-timeline__marker {
background: $semantic-color-success;
border-color: $semantic-color-success;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: $semantic-color-on-success;
font-size: $semantic-typography-font-size-xs;
font-weight: $semantic-typography-font-weight-bold;
}
}
}
&--active {
.ui-timeline__marker {
background: $semantic-color-primary;
border-color: $semantic-color-primary;
box-shadow: $semantic-shadow-elevation-2;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 50%;
height: 50%;
background: $semantic-color-on-primary;
border-radius: $semantic-border-radius-full;
transform: translate(-50%, -50%);
}
}
.ui-timeline__content {
color: $semantic-color-text-primary;
}
.ui-timeline__title {
color: $semantic-color-primary;
font-weight: $semantic-typography-font-weight-semibold;
}
}
&--pending {
.ui-timeline__marker {
background: $semantic-color-surface-primary;
border-color: $semantic-color-border-secondary;
}
.ui-timeline__content {
color: $semantic-color-text-secondary;
}
}
&--error {
.ui-timeline__marker {
background: $semantic-color-danger;
border-color: $semantic-color-danger;
&::after {
content: '×';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: $semantic-color-on-danger;
font-size: $semantic-typography-font-size-sm;
font-weight: $semantic-typography-font-weight-bold;
}
}
.ui-timeline__title {
color: $semantic-color-danger;
}
}
}
// Timeline Marker
&__marker {
flex-shrink: 0;
width: $semantic-sizing-icon-button;
height: $semantic-sizing-icon-button;
border: $semantic-border-width-2 solid $semantic-color-border-primary;
border-radius: $semantic-border-radius-full;
background: $semantic-color-surface-primary;
position: relative;
z-index: 1;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
}
// Timeline Content
&__content {
flex: 1;
margin-left: $semantic-spacing-component-sm;
// Remove padding-top for better alignment with marker center
}
&__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-bottom: $semantic-spacing-content-line-tight;
}
&__description {
color: $semantic-color-text-secondary;
margin-bottom: $semantic-spacing-content-line-tight;
}
&__timestamp {
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;
}
// Color Variants
&--primary {
.ui-timeline__item:not(.ui-timeline__item--completed):not(.ui-timeline__item--error) {
.ui-timeline__marker {
border-color: $semantic-color-primary;
}
&.ui-timeline__item--active .ui-timeline__marker {
background: $semantic-color-primary;
}
}
}
&--secondary {
.ui-timeline__item:not(.ui-timeline__item--completed):not(.ui-timeline__item--error) {
.ui-timeline__marker {
border-color: $semantic-color-secondary;
}
&.ui-timeline__item--active .ui-timeline__marker {
background: $semantic-color-secondary;
}
&.ui-timeline__item--active .ui-timeline__title {
color: $semantic-color-secondary;
}
}
}
&--success {
.ui-timeline__item:not(.ui-timeline__item--completed):not(.ui-timeline__item--error) {
.ui-timeline__marker {
border-color: $semantic-color-success;
}
&.ui-timeline__item--active .ui-timeline__marker {
background: $semantic-color-success;
}
&.ui-timeline__item--active .ui-timeline__title {
color: $semantic-color-success;
}
}
}
// Orientation Variants
&--horizontal {
flex-direction: row;
align-items: flex-start;
.ui-timeline__item {
flex-direction: column;
align-items: center;
text-align: center;
padding-bottom: 0;
padding-right: $semantic-spacing-component-md;
&:not(:last-child) {
&::after {
left: calc(100% - $semantic-spacing-component-md / 2);
top: calc($semantic-sizing-icon-button / 2 - $semantic-border-width-1 / 2);
width: $semantic-spacing-component-md;
height: $semantic-border-width-1;
}
}
}
.ui-timeline__content {
margin-left: 0;
margin-top: $semantic-spacing-content-line-tight;
padding-top: 0;
}
}
// Responsive Design
@media (max-width: ($semantic-breakpoint-md - 1)) {
padding: $semantic-spacing-component-sm;
&--horizontal {
flex-direction: column;
.ui-timeline__item {
flex-direction: row;
text-align: left;
padding-right: 0;
padding-bottom: $semantic-spacing-component-sm;
&:not(:last-child) {
&::after {
left: calc($semantic-sizing-icon-button / 2 - $semantic-border-width-1 / 2);
top: calc($semantic-sizing-icon-button + $semantic-spacing-content-line-tight);
width: $semantic-border-width-1;
height: calc(100% - $semantic-sizing-icon-button - $semantic-spacing-content-line-tight);
}
}
}
.ui-timeline__content {
margin-left: $semantic-spacing-component-sm;
margin-top: 0;
// Remove padding-top for better alignment
}
}
}
@media (max-width: ($semantic-breakpoint-sm - 1)) {
padding: $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);
}
}

View File

@@ -0,0 +1,115 @@
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
export type TimelineSize = 'sm' | 'md' | 'lg';
export type TimelineVariant = 'primary' | 'secondary' | 'success';
export type TimelineOrientation = 'vertical' | 'horizontal';
export type TimelineItemStatus = 'pending' | 'active' | 'completed' | 'error';
export interface TimelineItem {
id: string;
title: string;
description?: string;
timestamp?: string;
status: TimelineItemStatus;
}
@Component({
selector: 'ui-timeline',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-timeline"
[class]="getComponentClasses()"
[attr.role]="'list'"
[attr.aria-label]="ariaLabel || 'Timeline'">
@for (item of items; track item.id) {
<div
class="ui-timeline__item"
[class]="getItemClasses(item)"
[attr.role]="'listitem'"
[attr.aria-label]="getItemAriaLabel(item)">
<div
class="ui-timeline__marker"
[attr.aria-hidden]="true">
</div>
<div class="ui-timeline__content">
@if (item.title) {
<div class="ui-timeline__title">
{{ item.title }}
</div>
}
@if (item.description) {
<div class="ui-timeline__description">
{{ item.description }}
</div>
}
@if (item.timestamp) {
<div class="ui-timeline__timestamp">
{{ item.timestamp }}
</div>
}
</div>
</div>
}
</div>
`,
styleUrl: './timeline.component.scss'
})
export class TimelineComponent {
@Input() items: TimelineItem[] = [];
@Input() size: TimelineSize = 'md';
@Input() variant: TimelineVariant = 'primary';
@Input() orientation: TimelineOrientation = 'vertical';
@Input() ariaLabel?: string;
getComponentClasses(): string {
const classes = [
'ui-timeline',
`ui-timeline--${this.size}`,
`ui-timeline--${this.variant}`,
`ui-timeline--${this.orientation}`
];
return classes.join(' ');
}
getItemClasses(item: TimelineItem): string {
const classes = [
'ui-timeline__item',
`ui-timeline__item--${item.status}`
];
return classes.join(' ');
}
getItemAriaLabel(item: TimelineItem): string {
const status = this.getStatusLabel(item.status);
const title = item.title || 'Timeline item';
const timestamp = item.timestamp ? `, ${item.timestamp}` : '';
return `${title}, ${status}${timestamp}`;
}
private getStatusLabel(status: TimelineItemStatus): string {
switch (status) {
case 'completed':
return 'completed';
case 'active':
return 'in progress';
case 'pending':
return 'pending';
case 'error':
return 'error';
default:
return status;
}
}
}

View File

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

View File

@@ -0,0 +1,189 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-tooltip-wrapper {
position: relative;
display: inline-block;
}
.ui-tooltip-trigger {
display: inherit;
width: inherit;
height: inherit;
}
.ui-tooltip {
position: absolute;
z-index: $semantic-z-index-tooltip;
background: $semantic-color-surface-elevated;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
border-radius: $semantic-border-radius-md;
box-shadow: $semantic-shadow-elevation-3;
pointer-events: none;
opacity: 1;
transform: scale(1);
animation: tooltip-fade-in $semantic-motion-duration-fast $semantic-easing-standard;
// Size variants
&--sm {
max-width: 200px;
.ui-tooltip__content {
padding: $semantic-spacing-component-xs;
font-size: $semantic-typography-font-size-xs;
}
}
&--md {
max-width: 250px;
.ui-tooltip__content {
padding: $semantic-spacing-component-sm;
font-size: $semantic-typography-font-size-sm;
}
}
&--lg {
max-width: 300px;
.ui-tooltip__content {
padding: $semantic-spacing-component-md;
font-size: $semantic-typography-font-size-sm;
}
}
// Position variants
&--top {
bottom: 100%;
left: 50%;
transform: translateX(-50%) translateY(-8px);
margin-bottom: $semantic-spacing-component-xs;
.ui-tooltip__arrow {
top: 100%;
left: 50%;
transform: translateX(-50%);
border-top-color: $semantic-color-surface-elevated;
border-bottom: none;
}
}
&--bottom {
top: 100%;
left: 50%;
transform: translateX(-50%) translateY(8px);
margin-top: $semantic-spacing-component-xs;
.ui-tooltip__arrow {
bottom: 100%;
left: 50%;
transform: translateX(-50%);
border-bottom-color: $semantic-color-surface-elevated;
border-top: none;
}
}
&--left {
right: 100%;
top: 50%;
transform: translateY(-50%) translateX(-8px);
margin-right: $semantic-spacing-component-xs;
.ui-tooltip__arrow {
left: 100%;
top: 50%;
transform: translateY(-50%);
border-left-color: $semantic-color-surface-elevated;
border-right: none;
}
}
&--right {
left: 100%;
top: 50%;
transform: translateY(-50%) translateX(8px);
margin-left: $semantic-spacing-component-xs;
.ui-tooltip__arrow {
right: 100%;
top: 50%;
transform: translateY(-50%);
border-right-color: $semantic-color-surface-elevated;
border-left: none;
}
}
&__content {
color: $semantic-color-text-primary;
line-height: 1.4;
word-wrap: break-word;
text-align: center;
}
&__arrow {
position: absolute;
width: 0;
height: 0;
border: 6px solid transparent;
// Default arrow styling
border-top: 6px solid $semantic-color-surface-elevated;
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.1));
}
// Dark mode support
:host-context(.dark-theme) & {
background: $semantic-color-surface-secondary;
border-color: $semantic-color-border-primary;
.ui-tooltip__content {
color: $semantic-color-text-primary;
}
.ui-tooltip__arrow {
border-top-color: $semantic-color-surface-secondary;
&::after {
border-top-color: $semantic-color-surface-secondary;
}
}
&--bottom .ui-tooltip__arrow {
border-bottom-color: $semantic-color-surface-secondary;
border-top: none;
}
&--left .ui-tooltip__arrow {
border-left-color: $semantic-color-surface-secondary;
border-right: none;
}
&--right .ui-tooltip__arrow {
border-right-color: $semantic-color-surface-secondary;
border-left: none;
}
}
// Responsive adjustments
@media (max-width: $semantic-breakpoint-sm - 1) {
&--sm { max-width: 150px; }
&--md { max-width: 200px; }
&--lg { max-width: 250px; }
.ui-tooltip__content {
padding: $semantic-spacing-component-xs;
font-size: $semantic-typography-font-size-xs;
}
}
}
// Animation keyframes
@keyframes tooltip-fade-in {
from {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
}
}

View File

@@ -0,0 +1,152 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnDestroy, ElementRef, ViewChild, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
type TooltipPosition = 'top' | 'bottom' | 'left' | 'right';
type TooltipTrigger = 'hover' | 'click' | 'focus';
type TooltipSize = 'sm' | 'md' | 'lg';
@Component({
selector: 'ui-tooltip',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-tooltip-wrapper"
(mouseenter)="handleMouseEnter()"
(mouseleave)="handleMouseLeave()"
(click)="handleClick($event)"
(focus)="handleFocus()"
(blur)="handleBlur()">
<!-- Trigger content -->
<div class="ui-tooltip-trigger">
<ng-content></ng-content>
</div>
<!-- Tooltip content -->
@if (isVisible()) {
<div
#tooltipElement
class="ui-tooltip"
[class.ui-tooltip--top]="position === 'top'"
[class.ui-tooltip--bottom]="position === 'bottom'"
[class.ui-tooltip--left]="position === 'left'"
[class.ui-tooltip--right]="position === 'right'"
[class.ui-tooltip--sm]="size === 'sm'"
[class.ui-tooltip--md]="size === 'md'"
[class.ui-tooltip--lg]="size === 'lg'"
[attr.role]="'tooltip'"
[attr.aria-hidden]="!isVisible()">
<div class="ui-tooltip__content">
{{ text }}
</div>
<div class="ui-tooltip__arrow"></div>
</div>
}
</div>
`,
styleUrl: './tooltip.component.scss'
})
export class TooltipComponent implements OnDestroy {
@ViewChild('tooltipElement') tooltipElement?: ElementRef;
@Input() text = '';
@Input() position: TooltipPosition = 'top';
@Input() trigger: TooltipTrigger = 'hover';
@Input() size: TooltipSize = 'md';
@Input() disabled = false;
@Input() delay = 500;
@Output() tooltipShow = new EventEmitter<void>();
@Output() tooltipHide = new EventEmitter<void>();
protected isVisible = signal(false);
private showTimeout?: number;
private hideTimeout?: number;
ngOnDestroy(): void {
this.clearTimeouts();
}
handleMouseEnter(): void {
if (this.trigger === 'hover' && !this.disabled) {
this.scheduleShow();
}
}
handleMouseLeave(): void {
if (this.trigger === 'hover') {
this.scheduleHide();
}
}
handleClick(event: MouseEvent): void {
if (this.trigger === 'click' && !this.disabled) {
event.preventDefault();
this.toggle();
}
}
handleFocus(): void {
if (this.trigger === 'focus' && !this.disabled) {
this.show();
}
}
handleBlur(): void {
if (this.trigger === 'focus') {
this.hide();
}
}
private scheduleShow(): void {
this.clearTimeouts();
this.showTimeout = window.setTimeout(() => {
this.show();
}, this.delay);
}
private scheduleHide(): void {
this.clearTimeouts();
this.hideTimeout = window.setTimeout(() => {
this.hide();
}, 100);
}
private show(): void {
if (!this.disabled && !this.isVisible()) {
this.isVisible.set(true);
this.tooltipShow.emit();
}
}
private hide(): void {
if (this.isVisible()) {
this.isVisible.set(false);
this.tooltipHide.emit();
}
}
private toggle(): void {
if (this.isVisible()) {
this.hide();
} else {
this.show();
}
}
private clearTimeouts(): void {
if (this.showTimeout) {
clearTimeout(this.showTimeout);
this.showTimeout = undefined;
}
if (this.hideTimeout) {
clearTimeout(this.hideTimeout);
this.hideTimeout = undefined;
}
}
}

View File

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

View File

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

View File

@@ -0,0 +1,629 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, signal, computed } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import {
faChevronRight,
faChevronLeft,
faAnglesRight,
faAnglesLeft,
faSearch,
faTimes
} from '@fortawesome/free-solid-svg-icons';
export interface TransferListItem {
id: string | number;
label: string;
disabled?: boolean;
data?: any;
}
export type TransferListSize = 'sm' | 'md' | 'lg';
@Component({
selector: 'ui-transfer-list',
standalone: true,
imports: [CommonModule, FormsModule, FontAwesomeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-transfer-list"
[class.ui-transfer-list--{{size}}]="size"
[class.ui-transfer-list--disabled]="disabled"
role="application"
[attr.aria-label]="ariaLabel || 'Transfer list'"
>
<!-- Source List -->
<div class="ui-transfer-list__panel ui-transfer-list__panel--source">
<div class="ui-transfer-list__header">
<h3 class="ui-transfer-list__title">{{ sourceTitle }}</h3>
@if (showSearch) {
<div class="ui-transfer-list__search">
<fa-icon [icon]="faSearch" class="ui-transfer-list__search-icon"></fa-icon>
<input
type="text"
class="ui-transfer-list__search-input"
[(ngModel)]="sourceSearchTerm"
[placeholder]="searchPlaceholder"
[disabled]="disabled"
(input)="updateSourceSearch()"
/>
@if (sourceSearchTerm()) {
<button
type="button"
class="ui-transfer-list__search-clear"
(click)="clearSourceSearch()"
[disabled]="disabled"
aria-label="Clear search"
>
<fa-icon [icon]="faTimes"></fa-icon>
</button>
}
</div>
}
@if (showSelectAll) {
<label class="ui-transfer-list__select-all">
<input
type="checkbox"
[checked]="isAllSourceSelected()"
[indeterminate]="isSourceIndeterminate()"
[disabled]="disabled || filteredSourceItems().length === 0"
(change)="toggleAllSource($event)"
/>
<span>Select all ({{ getSourceSelectedCount() }}/{{ filteredSourceItems().length }})</span>
</label>
}
</div>
<div class="ui-transfer-list__list-container">
<div
class="ui-transfer-list__list"
role="listbox"
[attr.aria-label]="sourceTitle + ' items'"
tabindex="0"
(keydown)="handleSourceKeydown($event)"
>
@if (filteredSourceItems().length === 0) {
<div class="ui-transfer-list__empty" role="option">
@if (sourceSearchTerm()) {
No items match your search
} @else {
No items available
}
</div>
} @else {
@for (item of filteredSourceItems(); track item.id) {
<div
class="ui-transfer-list__item"
[class.ui-transfer-list__item--selected]="isSourceItemSelected(item.id)"
[class.ui-transfer-list__item--disabled]="disabled || item.disabled"
role="option"
[attr.aria-selected]="isSourceItemSelected(item.id)"
[attr.aria-disabled]="disabled || item.disabled"
[tabindex]="disabled || item.disabled ? -1 : 0"
(click)="toggleSourceItem(item)"
(keydown)="handleItemKeydown($event, item, 'source')"
>
@if (showCheckboxes) {
<input
type="checkbox"
class="ui-transfer-list__checkbox"
[checked]="isSourceItemSelected(item.id)"
[disabled]="disabled || item.disabled"
(change)="toggleSourceItem(item)"
tabindex="-1"
/>
}
<span class="ui-transfer-list__item-label">{{ item.label }}</span>
</div>
}
}
</div>
</div>
<div class="ui-transfer-list__footer">
{{ getSourceSelectedCount() }} of {{ filteredSourceItems().length }} selected
</div>
</div>
<!-- Transfer Controls -->
<div class="ui-transfer-list__controls">
<button
type="button"
class="ui-transfer-list__control-btn"
[disabled]="disabled || selectedSourceItems().length === 0"
(click)="moveSelectedToTarget()"
[attr.aria-label]="'Move ' + selectedSourceItems().length + ' selected items to ' + targetTitle"
title="Move selected items"
>
<fa-icon [icon]="faChevronRight"></fa-icon>
</button>
@if (showMoveAll) {
<button
type="button"
class="ui-transfer-list__control-btn"
[disabled]="disabled || filteredSourceItems().length === 0"
(click)="moveAllToTarget()"
[attr.aria-label]="'Move all ' + filteredSourceItems().length + ' items to ' + targetTitle"
title="Move all items"
>
<fa-icon [icon]="faAnglesRight"></fa-icon>
</button>
}
@if (showMoveAll) {
<button
type="button"
class="ui-transfer-list__control-btn"
[disabled]="disabled || filteredTargetItems().length === 0"
(click)="moveAllToSource()"
[attr.aria-label]="'Move all ' + filteredTargetItems().length + ' items back to ' + sourceTitle"
title="Move all items back"
>
<fa-icon [icon]="faAnglesLeft"></fa-icon>
</button>
}
<button
type="button"
class="ui-transfer-list__control-btn"
[disabled]="disabled || selectedTargetItems().length === 0"
(click)="moveSelectedToSource()"
[attr.aria-label]="'Move ' + selectedTargetItems().length + ' selected items back to ' + sourceTitle"
title="Move selected items back"
>
<fa-icon [icon]="faChevronLeft"></fa-icon>
</button>
</div>
<!-- Target List -->
<div class="ui-transfer-list__panel ui-transfer-list__panel--target">
<div class="ui-transfer-list__header">
<h3 class="ui-transfer-list__title">{{ targetTitle }}</h3>
@if (showSearch) {
<div class="ui-transfer-list__search">
<fa-icon [icon]="faSearch" class="ui-transfer-list__search-icon"></fa-icon>
<input
type="text"
class="ui-transfer-list__search-input"
[(ngModel)]="targetSearchTerm"
[placeholder]="searchPlaceholder"
[disabled]="disabled"
(input)="updateTargetSearch()"
/>
@if (targetSearchTerm()) {
<button
type="button"
class="ui-transfer-list__search-clear"
(click)="clearTargetSearch()"
[disabled]="disabled"
aria-label="Clear search"
>
<fa-icon [icon]="faTimes"></fa-icon>
</button>
}
</div>
}
@if (showSelectAll) {
<label class="ui-transfer-list__select-all">
<input
type="checkbox"
[checked]="isAllTargetSelected()"
[indeterminate]="isTargetIndeterminate()"
[disabled]="disabled || filteredTargetItems().length === 0"
(change)="toggleAllTarget($event)"
/>
<span>Select all ({{ getTargetSelectedCount() }}/{{ filteredTargetItems().length }})</span>
</label>
}
</div>
<div class="ui-transfer-list__list-container">
<div
class="ui-transfer-list__list"
role="listbox"
[attr.aria-label]="targetTitle + ' items'"
tabindex="0"
(keydown)="handleTargetKeydown($event)"
>
@if (filteredTargetItems().length === 0) {
<div class="ui-transfer-list__empty" role="option">
@if (targetSearchTerm()) {
No items match your search
} @else {
No items selected
}
</div>
} @else {
@for (item of filteredTargetItems(); track item.id) {
<div
class="ui-transfer-list__item"
[class.ui-transfer-list__item--selected]="isTargetItemSelected(item.id)"
[class.ui-transfer-list__item--disabled]="disabled || item.disabled"
role="option"
[attr.aria-selected]="isTargetItemSelected(item.id)"
[attr.aria-disabled]="disabled || item.disabled"
[tabindex]="disabled || item.disabled ? -1 : 0"
(click)="toggleTargetItem(item)"
(keydown)="handleItemKeydown($event, item, 'target')"
>
@if (showCheckboxes) {
<input
type="checkbox"
class="ui-transfer-list__checkbox"
[checked]="isTargetItemSelected(item.id)"
[disabled]="disabled || item.disabled"
(change)="toggleTargetItem(item)"
tabindex="-1"
/>
}
<span class="ui-transfer-list__item-label">{{ item.label }}</span>
</div>
}
}
</div>
</div>
<div class="ui-transfer-list__footer">
{{ getTargetSelectedCount() }} of {{ filteredTargetItems().length }} selected
</div>
</div>
</div>
`,
styleUrl: './transfer-list.component.scss'
})
export class TransferListComponent {
// FontAwesome icons
faChevronRight = faChevronRight;
faChevronLeft = faChevronLeft;
faAnglesRight = faAnglesRight;
faAnglesLeft = faAnglesLeft;
faSearch = faSearch;
faTimes = faTimes;
// Component inputs
@Input() sourceItems: TransferListItem[] = [];
@Input() targetItems: TransferListItem[] = [];
@Input() sourceTitle = 'Available';
@Input() targetTitle = 'Selected';
@Input() size: TransferListSize = 'md';
@Input() disabled = false;
@Input() showSearch = true;
@Input() showSelectAll = true;
@Input() showCheckboxes = true;
@Input() showMoveAll = true;
@Input() searchPlaceholder = 'Search items...';
@Input() ariaLabel?: string;
// Component outputs
@Output() sourceChange = new EventEmitter<TransferListItem[]>();
@Output() targetChange = new EventEmitter<TransferListItem[]>();
@Output() itemMove = new EventEmitter<{
item: TransferListItem;
from: 'source' | 'target';
to: 'source' | 'target';
}>();
// Internal signals for reactive state management
private sourceItemsSignal = signal<TransferListItem[]>([]);
private targetItemsSignal = signal<TransferListItem[]>([]);
private selectedSourceIds = signal<Set<string | number>>(new Set());
private selectedTargetIds = signal<Set<string | number>>(new Set());
// Search terms
sourceSearchTerm = signal<string>('');
targetSearchTerm = signal<string>('');
// Computed filtered items
filteredSourceItems = computed(() => {
const items = this.sourceItemsSignal();
const searchTerm = this.sourceSearchTerm().toLowerCase().trim();
if (!searchTerm) return items;
return items.filter(item =>
item.label.toLowerCase().includes(searchTerm)
);
});
filteredTargetItems = computed(() => {
const items = this.targetItemsSignal();
const searchTerm = this.targetSearchTerm().toLowerCase().trim();
if (!searchTerm) return items;
return items.filter(item =>
item.label.toLowerCase().includes(searchTerm)
);
});
// Computed selected items
selectedSourceItems = computed(() => {
const selectedIds = this.selectedSourceIds();
return this.sourceItemsSignal().filter(item => selectedIds.has(item.id));
});
selectedTargetItems = computed(() => {
const selectedIds = this.selectedTargetIds();
return this.targetItemsSignal().filter(item => selectedIds.has(item.id));
});
ngOnInit() {
this.sourceItemsSignal.set([...this.sourceItems]);
this.targetItemsSignal.set([...this.targetItems]);
}
ngOnChanges() {
this.sourceItemsSignal.set([...this.sourceItems]);
this.targetItemsSignal.set([...this.targetItems]);
}
// Search methods
updateSourceSearch() {
// Clear selection when searching to avoid confusion
this.selectedSourceIds.set(new Set());
}
updateTargetSearch() {
// Clear selection when searching to avoid confusion
this.selectedTargetIds.set(new Set());
}
clearSourceSearch() {
this.sourceSearchTerm.set('');
this.selectedSourceIds.set(new Set());
}
clearTargetSearch() {
this.targetSearchTerm.set('');
this.selectedTargetIds.set(new Set());
}
// Selection methods
isSourceItemSelected(id: string | number): boolean {
return this.selectedSourceIds().has(id);
}
isTargetItemSelected(id: string | number): boolean {
return this.selectedTargetIds().has(id);
}
toggleSourceItem(item: TransferListItem) {
if (this.disabled || item.disabled) return;
const currentSelected = new Set(this.selectedSourceIds());
if (currentSelected.has(item.id)) {
currentSelected.delete(item.id);
} else {
currentSelected.add(item.id);
}
this.selectedSourceIds.set(currentSelected);
}
toggleTargetItem(item: TransferListItem) {
if (this.disabled || item.disabled) return;
const currentSelected = new Set(this.selectedTargetIds());
if (currentSelected.has(item.id)) {
currentSelected.delete(item.id);
} else {
currentSelected.add(item.id);
}
this.selectedTargetIds.set(currentSelected);
}
// Select all methods
getSourceSelectedCount(): number {
return this.selectedSourceIds().size;
}
getTargetSelectedCount(): number {
return this.selectedTargetIds().size;
}
isAllSourceSelected(): boolean {
const availableItems = this.filteredSourceItems().filter(item => !item.disabled);
return availableItems.length > 0 &&
availableItems.every(item => this.selectedSourceIds().has(item.id));
}
isAllTargetSelected(): boolean {
const availableItems = this.filteredTargetItems().filter(item => !item.disabled);
return availableItems.length > 0 &&
availableItems.every(item => this.selectedTargetIds().has(item.id));
}
isSourceIndeterminate(): boolean {
const availableItems = this.filteredSourceItems().filter(item => !item.disabled);
const selectedCount = availableItems.filter(item => this.selectedSourceIds().has(item.id)).length;
return selectedCount > 0 && selectedCount < availableItems.length;
}
isTargetIndeterminate(): boolean {
const availableItems = this.filteredTargetItems().filter(item => !item.disabled);
const selectedCount = availableItems.filter(item => this.selectedTargetIds().has(item.id)).length;
return selectedCount > 0 && selectedCount < availableItems.length;
}
toggleAllSource(event: Event) {
if (this.disabled) return;
const target = event.target as HTMLInputElement;
const availableItems = this.filteredSourceItems().filter(item => !item.disabled);
if (target.checked) {
const newSelected = new Set(this.selectedSourceIds());
availableItems.forEach(item => newSelected.add(item.id));
this.selectedSourceIds.set(newSelected);
} else {
const newSelected = new Set(this.selectedSourceIds());
availableItems.forEach(item => newSelected.delete(item.id));
this.selectedSourceIds.set(newSelected);
}
}
toggleAllTarget(event: Event) {
if (this.disabled) return;
const target = event.target as HTMLInputElement;
const availableItems = this.filteredTargetItems().filter(item => !item.disabled);
if (target.checked) {
const newSelected = new Set(this.selectedTargetIds());
availableItems.forEach(item => newSelected.add(item.id));
this.selectedTargetIds.set(newSelected);
} else {
const newSelected = new Set(this.selectedTargetIds());
availableItems.forEach(item => newSelected.delete(item.id));
this.selectedTargetIds.set(newSelected);
}
}
// Transfer methods
moveSelectedToTarget() {
if (this.disabled) return;
const itemsToMove = this.selectedSourceItems();
if (itemsToMove.length === 0) return;
const newSourceItems = this.sourceItemsSignal().filter(
item => !this.selectedSourceIds().has(item.id)
);
const newTargetItems = [...this.targetItemsSignal(), ...itemsToMove];
this.sourceItemsSignal.set(newSourceItems);
this.targetItemsSignal.set(newTargetItems);
this.selectedSourceIds.set(new Set());
// Emit events
this.sourceChange.emit(newSourceItems);
this.targetChange.emit(newTargetItems);
itemsToMove.forEach(item => {
this.itemMove.emit({ item, from: 'source', to: 'target' });
});
}
moveSelectedToSource() {
if (this.disabled) return;
const itemsToMove = this.selectedTargetItems();
if (itemsToMove.length === 0) return;
const newTargetItems = this.targetItemsSignal().filter(
item => !this.selectedTargetIds().has(item.id)
);
const newSourceItems = [...this.sourceItemsSignal(), ...itemsToMove];
this.sourceItemsSignal.set(newSourceItems);
this.targetItemsSignal.set(newTargetItems);
this.selectedTargetIds.set(new Set());
// Emit events
this.sourceChange.emit(newSourceItems);
this.targetChange.emit(newTargetItems);
itemsToMove.forEach(item => {
this.itemMove.emit({ item, from: 'target', to: 'source' });
});
}
moveAllToTarget() {
if (this.disabled) return;
const itemsToMove = this.filteredSourceItems().filter(item => !item.disabled);
if (itemsToMove.length === 0) return;
const newSourceItems = this.sourceItemsSignal().filter(item => item.disabled);
const newTargetItems = [...this.targetItemsSignal(), ...itemsToMove];
this.sourceItemsSignal.set(newSourceItems);
this.targetItemsSignal.set(newTargetItems);
this.selectedSourceIds.set(new Set());
// Emit events
this.sourceChange.emit(newSourceItems);
this.targetChange.emit(newTargetItems);
itemsToMove.forEach(item => {
this.itemMove.emit({ item, from: 'source', to: 'target' });
});
}
moveAllToSource() {
if (this.disabled) return;
const itemsToMove = this.filteredTargetItems().filter(item => !item.disabled);
if (itemsToMove.length === 0) return;
const newTargetItems = this.targetItemsSignal().filter(item => item.disabled);
const newSourceItems = [...this.sourceItemsSignal(), ...itemsToMove];
this.sourceItemsSignal.set(newSourceItems);
this.targetItemsSignal.set(newTargetItems);
this.selectedTargetIds.set(new Set());
// Emit events
this.sourceChange.emit(newSourceItems);
this.targetChange.emit(newTargetItems);
itemsToMove.forEach(item => {
this.itemMove.emit({ item, from: 'target', to: 'source' });
});
}
// Keyboard navigation
handleSourceKeydown(event: KeyboardEvent) {
this.handleListKeydown(event, this.filteredSourceItems(), 'source');
}
handleTargetKeydown(event: KeyboardEvent) {
this.handleListKeydown(event, this.filteredTargetItems(), 'target');
}
handleItemKeydown(event: KeyboardEvent, item: TransferListItem, list: 'source' | 'target') {
if (event.key === ' ' || event.key === 'Enter') {
event.preventDefault();
if (list === 'source') {
this.toggleSourceItem(item);
} else {
this.toggleTargetItem(item);
}
}
}
private handleListKeydown(event: KeyboardEvent, items: TransferListItem[], list: 'source' | 'target') {
const currentFocus = document.activeElement as HTMLElement;
const listItems = Array.from(currentFocus.parentElement?.querySelectorAll('.ui-transfer-list__item[tabindex="0"]') || []) as HTMLElement[];
if (listItems.length === 0) return;
const currentIndex = listItems.indexOf(currentFocus);
let nextIndex = currentIndex;
switch (event.key) {
case 'ArrowDown':
event.preventDefault();
nextIndex = Math.min(currentIndex + 1, listItems.length - 1);
break;
case 'ArrowUp':
event.preventDefault();
nextIndex = Math.max(currentIndex - 1, 0);
break;
case 'Home':
event.preventDefault();
nextIndex = 0;
break;
case 'End':
event.preventDefault();
nextIndex = listItems.length - 1;
break;
}
if (nextIndex !== currentIndex) {
listItems[nextIndex]?.focus();
}
}
}

View File

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

View File

@@ -0,0 +1,225 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-tree-view {
// Core Structure
display: block;
position: relative;
// Layout & Spacing
padding: $semantic-spacing-component-sm;
// Visual Design
background: $semantic-color-surface-primary;
border: $semantic-border-width-1 solid $semantic-color-border-subtle;
border-radius: $semantic-border-card-radius;
// 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;
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;
// Typography already set above for medium
}
&--lg {
padding: $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);
}
// Color Variants
&--primary {
border-color: $semantic-color-primary;
.ui-tree-view__node--selected {
background: $semantic-color-primary;
color: $semantic-color-on-primary;
}
}
&--secondary {
border-color: $semantic-color-secondary;
.ui-tree-view__node--selected {
background: $semantic-color-secondary;
color: $semantic-color-on-secondary;
}
}
// Tree Node
&__node {
display: flex;
align-items: center;
padding: $semantic-spacing-content-line-tight;
margin-bottom: 2px;
cursor: pointer;
border-radius: $semantic-border-radius-sm;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
// Node states
&:hover {
background: $semantic-color-surface-secondary;
}
&--selected {
background: $semantic-color-surface-elevated;
color: $semantic-color-text-primary;
font-weight: $semantic-typography-font-weight-medium;
}
&--disabled {
opacity: $semantic-opacity-disabled;
cursor: not-allowed;
pointer-events: none;
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
}
// Tree Node Content
&__node-content {
display: flex;
align-items: center;
flex: 1;
min-width: 0; // Allow text truncation
}
// Expand/Collapse Button
&__expand-button {
display: flex;
align-items: center;
justify-content: center;
width: $semantic-sizing-touch-minimum;
height: $semantic-sizing-touch-minimum;
margin-right: $semantic-spacing-component-xs;
border: none;
background: transparent;
border-radius: $semantic-border-radius-sm;
cursor: pointer;
transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease;
&:hover {
background: $semantic-color-surface-secondary;
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
&--expanded {
transform: rotate(90deg);
}
&--leaf {
cursor: default;
color: $semantic-color-text-secondary;
font-size: $semantic-typography-font-size-lg;
&:hover {
background: transparent;
}
}
}
// Node Icon
&__node-icon {
width: $semantic-sizing-icon-inline;
height: $semantic-sizing-icon-inline;
margin-right: $semantic-spacing-component-xs;
color: $semantic-color-text-secondary;
.ui-tree-view__node--selected & {
color: inherit;
}
}
// Node Label
&__node-label {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
// Children Container
&__children {
margin-left: $semantic-spacing-layout-section-sm;
border-left: $semantic-border-width-1 solid $semantic-color-border-subtle;
padding-left: $semantic-spacing-component-sm;
&--hidden {
display: none;
}
}
// Loading State
&__loading {
display: flex;
align-items: center;
justify-content: center;
padding: $semantic-spacing-component-md;
color: $semantic-color-text-secondary;
}
// Empty State
&__empty {
display: flex;
align-items: center;
justify-content: center;
padding: $semantic-spacing-component-lg;
color: $semantic-color-text-secondary;
font-style: italic;
}
// Root level adjustments
& > .ui-tree-view__node {
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
// Responsive Design
@media (max-width: ($semantic-breakpoint-md - 1)) {
padding: $semantic-spacing-component-xs;
.ui-tree-view__children {
margin-left: $semantic-spacing-component-md;
padding-left: $semantic-spacing-component-xs;
}
}
@media (max-width: ($semantic-breakpoint-sm - 1)) {
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-tree-view__node {
padding: $semantic-spacing-content-line-tight $semantic-spacing-component-xs;
}
}
}

View File

@@ -0,0 +1,297 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface TreeNode {
id: string;
label: string;
icon?: string;
children?: TreeNode[];
expanded?: boolean;
selected?: boolean;
disabled?: boolean;
data?: any;
}
type TreeViewSize = 'sm' | 'md' | 'lg';
type TreeViewVariant = 'primary' | 'secondary';
@Component({
selector: 'ui-tree-node',
standalone: true,
imports: [CommonModule, TreeNodeComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-tree-view__node"
[class.ui-tree-view__node--selected]="node.selected"
[class.ui-tree-view__node--disabled]="node.disabled"
[style.paddingLeft.px]="level * 20"
[attr.aria-selected]="node.selected"
[attr.aria-expanded]="hasChildren ? node.expanded : null"
[attr.aria-level]="level + 1"
[attr.aria-disabled]="node.disabled"
[tabindex]="node.disabled ? -1 : 0"
role="treeitem"
(click)="handleNodeClick()"
(keydown)="handleKeydown($event)">
@if (hasChildren) {
<button
class="ui-tree-view__expand-button"
[class.ui-tree-view__expand-button--expanded]="node.expanded"
[attr.aria-label]="node.expanded ? 'Collapse' : 'Expand'"
[disabled]="node.disabled"
(click)="handleToggleClick($event)"
type="button">
</button>
} @else {
<div class="ui-tree-view__expand-button ui-tree-view__expand-button--leaf">
</div>
}
<div class="ui-tree-view__node-content">
@if (showIcons && node.icon) {
<span class="ui-tree-view__node-icon" [attr.aria-hidden]="true">
{{ node.icon }}
</span>
}
<span class="ui-tree-view__node-label">
{{ node.label }}
</span>
</div>
</div>
@if (hasChildren && node.expanded) {
<div class="ui-tree-view__children"
[class.ui-tree-view__children--hidden]="!node.expanded"
role="group">
@for (childNode of node.children; track childNode.id) {
<ui-tree-node
[node]="childNode"
[level]="level + 1"
[showIcons]="showIcons"
[multiSelect]="multiSelect"
(nodeToggled)="nodeToggled.emit($event)"
(nodeSelected)="nodeSelected.emit($event)">
</ui-tree-node>
}
</div>
}
`
})
export class TreeNodeComponent {
@Input() node!: TreeNode;
@Input() level = 0;
@Input() showIcons = true;
@Input() multiSelect = false;
@Output() nodeToggled = new EventEmitter<{ node: TreeNode; expanded: boolean }>();
@Output() nodeSelected = new EventEmitter<{ node: TreeNode; selected: boolean }>();
get hasChildren(): boolean {
return !!(this.node.children && this.node.children.length > 0);
}
handleNodeClick(): void {
if (!this.node.disabled) {
this.nodeSelected.emit({
node: this.node,
selected: !this.node.selected
});
}
}
handleToggleClick(event: Event): void {
event.stopPropagation();
if (!this.node.disabled && this.hasChildren) {
this.nodeToggled.emit({
node: this.node,
expanded: !this.node.expanded
});
}
}
handleKeydown(event: KeyboardEvent): void {
if (this.node.disabled) return;
switch (event.key) {
case 'Enter':
case ' ':
event.preventDefault();
this.handleNodeClick();
break;
case 'ArrowRight':
if (this.hasChildren && !this.node.expanded) {
event.preventDefault();
this.handleToggleClick(event);
}
break;
case 'ArrowLeft':
if (this.hasChildren && this.node.expanded) {
event.preventDefault();
this.handleToggleClick(event);
}
break;
}
}
}
@Component({
selector: 'ui-tree-view',
standalone: true,
imports: [CommonModule, TreeNodeComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-tree-view"
[class.ui-tree-view--sm]="size === 'sm'"
[class.ui-tree-view--md]="size === 'md'"
[class.ui-tree-view--lg]="size === 'lg'"
[class.ui-tree-view--primary]="variant === 'primary'"
[class.ui-tree-view--secondary]="variant === 'secondary'"
[attr.aria-label]="ariaLabel"
role="tree">
@if (loading) {
<div class="ui-tree-view__loading" role="status" aria-live="polite">
Loading...
</div>
} @else if (!nodes || nodes.length === 0) {
<div class="ui-tree-view__empty" role="status">
{{ emptyMessage }}
</div>
} @else {
@for (node of nodes; track node.id) {
<div class="ui-tree-view-node-wrapper">
@if (renderTreeNode) {
<ng-container [ngTemplateOutlet]="renderTreeNode"
[ngTemplateOutletContext]="{
$implicit: node,
level: 0,
onToggle: handleToggle,
onSelect: handleSelect
}">
</ng-container>
} @else {
<ui-tree-node
[node]="node"
[level]="0"
[showIcons]="showIcons"
[multiSelect]="multiSelect"
(nodeToggled)="handleToggle($event)"
(nodeSelected)="handleSelect($event)">
</ui-tree-node>
}
</div>
}
}
</div>
`,
styleUrl: './tree-view.component.scss'
})
export class TreeViewComponent {
@Input() nodes: TreeNode[] = [];
@Input() size: TreeViewSize = 'md';
@Input() variant: TreeViewVariant = 'primary';
@Input() loading = false;
@Input() showIcons = true;
@Input() multiSelect = false;
@Input() expandAll = false;
@Input() emptyMessage = 'No items to display';
@Input() ariaLabel = 'Tree view';
@Input() renderTreeNode?: any; // Custom template reference
@Output() nodeToggled = new EventEmitter<{ node: TreeNode; expanded: boolean }>();
@Output() nodeSelected = new EventEmitter<{ node: TreeNode; selected: boolean }>();
@Output() nodesChanged = new EventEmitter<TreeNode[]>();
handleToggle = (event: { node: TreeNode; expanded: boolean }): void => {
event.node.expanded = event.expanded;
this.nodeToggled.emit(event);
this.nodesChanged.emit(this.nodes);
};
handleSelect = (event: { node: TreeNode; selected: boolean }): void => {
if (!this.multiSelect) {
// Single select - clear all other selections
this.clearAllSelections(this.nodes);
}
event.node.selected = event.selected;
this.nodeSelected.emit(event);
this.nodesChanged.emit(this.nodes);
};
private clearAllSelections(nodes: TreeNode[]): void {
for (const node of nodes) {
node.selected = false;
if (node.children) {
this.clearAllSelections(node.children);
}
}
}
// Public API methods
expandAllNodes(): void {
this.setAllExpanded(this.nodes, true);
this.nodesChanged.emit(this.nodes);
}
collapseAllNodes(): void {
this.setAllExpanded(this.nodes, false);
this.nodesChanged.emit(this.nodes);
}
getSelectedNodes(): TreeNode[] {
return this.findSelectedNodes(this.nodes);
}
selectNode(nodeId: string): void {
const node = this.findNodeById(this.nodes, nodeId);
if (node && !node.disabled) {
this.handleSelect({ node, selected: true });
}
}
private setAllExpanded(nodes: TreeNode[], expanded: boolean): void {
for (const node of nodes) {
if (node.children && node.children.length > 0) {
node.expanded = expanded;
this.setAllExpanded(node.children, expanded);
}
}
}
private findSelectedNodes(nodes: TreeNode[]): TreeNode[] {
const selected: TreeNode[] = [];
for (const node of nodes) {
if (node.selected) {
selected.push(node);
}
if (node.children) {
selected.push(...this.findSelectedNodes(node.children));
}
}
return selected;
}
private findNodeById(nodes: TreeNode[], id: string): TreeNode | null {
for (const node of nodes) {
if (node.id === id) {
return node;
}
if (node.children) {
const found = this.findNodeById(node.children, id);
if (found) {
return found;
}
}
}
return null;
}
}

View File

@@ -0,0 +1,221 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-alert {
// Core Structure
display: flex;
position: relative;
align-items: flex-start;
// Layout & Spacing
padding: $semantic-spacing-component-md;
margin-bottom: $semantic-spacing-component-sm;
gap: 16px;
// Visual Design
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;
// 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-fast $semantic-motion-easing-ease;
// Size Variants
&--sm {
padding: $semantic-spacing-component-sm;
gap: 12px;
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
}
&--md {
// Default styles already applied above
}
&--lg {
padding: $semantic-spacing-component-lg;
gap: 20px;
font-family: map-get($semantic-typography-body-large, font-family);
font-size: map-get($semantic-typography-body-large, font-size);
font-weight: map-get($semantic-typography-body-large, font-weight);
line-height: map-get($semantic-typography-body-large, line-height);
}
// Color Variants
&--primary {
background: $semantic-color-surface-elevated;
border-color: $semantic-color-primary;
border-left-width: 4px;
.ui-alert__icon {
color: $semantic-color-primary;
}
}
&--success {
background: $semantic-color-surface-elevated;
border-color: $semantic-color-success;
border-left-width: 4px;
.ui-alert__icon {
color: $semantic-color-success;
}
.ui-alert__title {
color: $semantic-color-success;
}
}
&--warning {
background: $semantic-color-surface-elevated;
border-color: $semantic-color-warning;
border-left-width: 4px;
.ui-alert__icon {
color: $semantic-color-warning;
}
.ui-alert__title {
color: $semantic-color-warning;
}
}
&--danger {
background: $semantic-color-surface-elevated;
border-color: $semantic-color-danger;
border-left-width: 4px;
.ui-alert__icon {
color: $semantic-color-danger;
}
.ui-alert__title {
color: $semantic-color-danger;
}
}
&--info {
background: $semantic-color-surface-elevated;
border-color: $semantic-color-info;
border-left-width: 4px;
.ui-alert__icon {
color: $semantic-color-info;
}
.ui-alert__title {
color: $semantic-color-info;
}
}
// BEM Elements
&__icon {
flex-shrink: 0;
color: $semantic-color-text-secondary;
font-size: 1.25rem; // Increased from $semantic-sizing-icon-inline for better visibility
margin-top: 2px; // Slight optical alignment
}
&__content {
flex: 1;
min-width: 0; // Prevents flex overflow
}
&__title {
margin: 0 0 $semantic-spacing-content-line-tight 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;
&--bold {
font-weight: $semantic-typography-font-weight-semibold;
}
}
&__message {
margin: 0;
color: $semantic-color-text-secondary;
}
&__actions {
margin-top: $semantic-spacing-component-sm;
display: flex;
gap: $semantic-spacing-component-xs;
flex-wrap: wrap;
}
&__dismiss {
position: absolute;
top: $semantic-spacing-component-xs;
right: $semantic-spacing-component-xs;
background: transparent;
border: none;
color: $semantic-color-text-tertiary;
cursor: pointer;
padding: $semantic-spacing-component-xs;
border-radius: $semantic-border-radius-sm;
display: flex;
align-items: center;
justify-content: center;
min-width: $semantic-sizing-touch-minimum;
min-height: $semantic-sizing-touch-minimum;
&:hover {
background: $semantic-color-surface-secondary;
color: $semantic-color-text-secondary;
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
&:active {
background: $semantic-color-surface-primary;
}
}
// State Variants
&--dismissible {
padding-right: calc($semantic-spacing-component-lg + $semantic-sizing-touch-minimum);
}
// Responsive Design
@media (max-width: $semantic-breakpoint-sm - 1) {
padding: $semantic-spacing-component-sm;
gap: $semantic-spacing-component-xs;
&--lg {
padding: $semantic-spacing-component-md;
}
&__title {
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);
}
&__message {
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);
}
&--dismissible {
padding-right: calc($semantic-spacing-component-md + $semantic-sizing-touch-minimum);
}
}
}

View File

@@ -0,0 +1,136 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } 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 AlertSize = 'sm' | 'md' | 'lg';
type AlertVariant = 'primary' | 'success' | 'warning' | 'danger' | 'info';
@Component({
selector: 'ui-alert',
standalone: true,
imports: [CommonModule, FontAwesomeModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-alert"
[class.ui-alert--sm]="size === 'sm'"
[class.ui-alert--md]="size === 'md'"
[class.ui-alert--lg]="size === 'lg'"
[class.ui-alert--primary]="variant === 'primary'"
[class.ui-alert--success]="variant === 'success'"
[class.ui-alert--warning]="variant === 'warning'"
[class.ui-alert--danger]="variant === 'danger'"
[class.ui-alert--info]="variant === 'info'"
[class.ui-alert--dismissible]="dismissible"
[attr.role]="role"
[attr.aria-live]="ariaLive"
[attr.aria-labelledby]="title ? 'alert-title-' + alertId : null"
[attr.aria-describedby]="'alert-content-' + alertId">
@if (showIcon && alertIcon) {
<fa-icon
class="ui-alert__icon"
[icon]="alertIcon"
[attr.aria-hidden]="true">
</fa-icon>
}
<div class="ui-alert__content">
@if (title) {
<h4
class="ui-alert__title"
[class.ui-alert__title--bold]="boldTitle"
[id]="'alert-title-' + alertId">
{{ title }}
</h4>
}
<div
class="ui-alert__message"
[id]="'alert-content-' + alertId">
<ng-content></ng-content>
</div>
@if (actions && actions.length > 0) {
<div class="ui-alert__actions">
<ng-content select="[slot=actions]"></ng-content>
</div>
}
</div>
@if (dismissible) {
<button
type="button"
class="ui-alert__dismiss"
[attr.aria-label]="dismissLabel"
(click)="handleDismiss()"
(keydown)="handleDismissKeydown($event)">
<fa-icon [icon]="faTimes" [attr.aria-hidden]="true"></fa-icon>
</button>
}
</div>
`,
styleUrl: './alert.component.scss'
})
export class AlertComponent {
@Input() size: AlertSize = 'md';
@Input() variant: AlertVariant = 'primary';
@Input() title?: string;
@Input() boldTitle = false;
@Input() showIcon = true;
@Input() dismissible = false;
@Input() dismissLabel = 'Dismiss alert';
@Input() role = 'alert';
@Input() ariaLive: 'polite' | 'assertive' | 'off' = 'polite';
@Input() actions: any[] = [];
@Output() dismissed = new EventEmitter<void>();
// Icons
readonly faCheckCircle = faCheckCircle;
readonly faExclamationTriangle = faExclamationTriangle;
readonly faExclamationCircle = faExclamationCircle;
readonly faInfoCircle = faInfoCircle;
readonly faTimes = faTimes;
// Generate unique ID for accessibility
readonly alertId = Math.random().toString(36).substr(2, 9);
get alertIcon(): 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':
default:
return this.faInfoCircle;
}
}
handleDismiss(): void {
this.dismissed.emit();
}
handleDismissKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.handleDismiss();
}
}
}

View File

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

View File

@@ -0,0 +1,309 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-empty-state {
// Core Structure
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
position: relative;
// Layout & Spacing
padding: $semantic-spacing-component-lg;
min-height: 200px;
// Visual Design
background: $semantic-color-surface-primary;
border-radius: $semantic-border-card-radius;
// Typography
color: $semantic-color-text-secondary;
// Sizing Variants
&--sm {
min-height: 120px;
padding: $semantic-spacing-component-sm;
.ui-empty-state__icon {
width: $semantic-sizing-icon-inline;
height: $semantic-sizing-icon-inline;
margin-bottom: $semantic-spacing-content-line-tight;
}
.ui-empty-state__title {
font-size: $base-typography-font-size-sm;
margin-bottom: $semantic-spacing-content-line-tight;
}
.ui-empty-state__description {
font-size: $base-typography-font-size-sm;
margin-bottom: $semantic-spacing-content-line-normal;
}
}
&--md {
min-height: 200px;
padding: $semantic-spacing-component-md;
.ui-empty-state__icon {
width: $semantic-sizing-icon-button;
height: $semantic-sizing-icon-button;
margin-bottom: $semantic-spacing-content-line-normal;
}
.ui-empty-state__title {
font-size: $base-typography-font-size-lg;
margin-bottom: $semantic-spacing-content-line-normal;
}
.ui-empty-state__description {
font-size: $base-typography-font-size-md;
margin-bottom: $semantic-spacing-content-paragraph;
}
}
&--lg {
min-height: 300px;
padding: $semantic-spacing-component-lg;
.ui-empty-state__icon {
width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
margin-bottom: $semantic-spacing-content-paragraph;
}
.ui-empty-state__title {
font-size: $base-typography-font-size-xl;
margin-bottom: $semantic-spacing-content-line-normal;
}
.ui-empty-state__description {
font-size: $base-typography-font-size-lg;
margin-bottom: $semantic-spacing-content-heading;
}
}
// Variant Styles
&--default {
.ui-empty-state__icon {
color: $semantic-color-text-tertiary;
}
}
&--search {
.ui-empty-state__icon {
color: $semantic-color-primary;
}
.ui-empty-state__title {
color: $semantic-color-text-primary;
}
}
&--error {
.ui-empty-state__icon {
color: $semantic-color-error;
}
.ui-empty-state__title {
color: $semantic-color-error;
}
}
&--loading {
.ui-empty-state__icon {
color: $semantic-color-primary;
}
}
&--success {
.ui-empty-state__icon {
color: $semantic-color-success;
}
.ui-empty-state__title {
color: $semantic-color-success;
}
}
}
.ui-empty-state__icon {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-bottom: $semantic-spacing-content-line-normal;
svg, img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
.ui-empty-state__spinner {
width: 100%;
height: 100%;
border: 3px solid $semantic-color-border-subtle;
border-top: 3px solid $semantic-color-primary;
border-radius: 50%;
animation: spin $semantic-duration-medium $semantic-easing-standard infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.ui-empty-state__title {
margin: 0 0 $semantic-spacing-content-line-normal 0;
color: $semantic-color-text-primary;
font-weight: $base-typography-font-weight-semibold;
line-height: $base-typography-line-height-tight;
}
.ui-empty-state__description {
margin: 0 0 $semantic-spacing-content-paragraph 0;
color: $semantic-color-text-secondary;
line-height: $base-typography-line-height-relaxed;
max-width: 400px;
}
.ui-empty-state__content {
margin-bottom: $semantic-spacing-content-paragraph;
&:empty {
display: none;
}
}
.ui-empty-state__actions {
display: flex;
gap: $semantic-spacing-content-line-normal;
justify-content: center;
flex-wrap: wrap;
}
.ui-empty-state__action {
display: inline-flex;
align-items: center;
justify-content: center;
padding: $semantic-spacing-component-sm $semantic-spacing-component-md;
// Visual Design
background: $semantic-color-primary;
color: $semantic-color-on-primary;
border: $semantic-border-button-width solid $semantic-color-primary;
border-radius: $semantic-border-button-radius;
box-shadow: $semantic-shadow-button-rest;
// Typography
font-size: $base-typography-font-size-md;
font-weight: $base-typography-font-weight-medium;
text-decoration: none;
// Transitions
transition: all $semantic-duration-fast $semantic-easing-standard;
cursor: pointer;
user-select: none;
// Interactive States
&:not([disabled]) {
&:hover {
background: $semantic-color-primary;
border-color: $semantic-color-primary;
box-shadow: $semantic-shadow-button-hover;
transform: translateY(-1px);
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
&:active {
background: $semantic-color-primary;
border-color: $semantic-color-primary;
box-shadow: $semantic-shadow-button-active;
transform: translateY(0);
}
}
&[disabled] {
opacity: 0.38;
cursor: not-allowed;
pointer-events: none;
}
}
// Dark Mode Support
:host-context(.dark-theme) .ui-empty-state {
background: $semantic-color-surface-secondary;
}
// Responsive Design
@media (max-width: 768px) {
.ui-empty-state {
padding: $semantic-spacing-component-md;
min-height: 160px;
&--lg {
min-height: 200px;
}
.ui-empty-state__description {
max-width: 300px;
}
}
}
@media (max-width: 480px) {
.ui-empty-state {
padding: $semantic-spacing-component-sm;
min-height: 120px;
.ui-empty-state__icon {
width: $semantic-sizing-icon-inline;
height: $semantic-sizing-icon-inline;
}
.ui-empty-state__title {
font-size: $base-typography-font-size-sm;
}
.ui-empty-state__description {
font-size: $base-typography-font-size-sm;
max-width: 280px;
}
}
}
// High contrast mode
@media (prefers-contrast: high) {
.ui-empty-state__action {
border-width: 2px;
font-weight: $base-typography-font-weight-bold;
}
}
// Reduced motion
@media (prefers-reduced-motion: reduce) {
.ui-empty-state__spinner {
animation: none;
border-top-color: $semantic-color-primary;
}
.ui-empty-state__action {
transition: none;
&:hover {
transform: none;
}
&:active {
transform: none;
}
}
}

View File

@@ -0,0 +1,85 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
type EmptyStateSize = 'sm' | 'md' | 'lg';
type EmptyStateVariant = 'default' | 'search' | 'error' | 'loading' | 'success';
@Component({
selector: 'ui-empty-state',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-empty-state"
[class]="getComponentClasses()"
[attr.role]="role"
[attr.aria-label]="ariaLabel">
@if (icon || variant === 'loading') {
<div class="ui-empty-state__icon" [attr.aria-hidden]="true">
@if (variant === 'loading') {
<div class="ui-empty-state__spinner"></div>
} @else if (icon) {
<span [innerHTML]="icon"></span>
}
</div>
}
@if (title) {
<h3 class="ui-empty-state__title">{{ title }}</h3>
}
@if (description) {
<p class="ui-empty-state__description">{{ description }}</p>
}
<div class="ui-empty-state__content">
<ng-content></ng-content>
</div>
@if (actionLabel) {
<div class="ui-empty-state__actions">
<button
type="button"
class="ui-empty-state__action"
(click)="handleAction($event)"
[disabled]="disabled">
{{ actionLabel }}
</button>
</div>
}
</div>
`,
styleUrl: './empty-state.component.scss'
})
export class EmptyStateComponent {
@Input() size: EmptyStateSize = 'md';
@Input() variant: EmptyStateVariant = 'default';
@Input() icon = '';
@Input() title = '';
@Input() description = '';
@Input() actionLabel = '';
@Input() disabled = false;
@Input() role = 'region';
@Input() ariaLabel = 'Empty state';
@Output() action = new EventEmitter<MouseEvent>();
getComponentClasses(): string {
const classes = [
'ui-empty-state',
`ui-empty-state--${this.size}`,
`ui-empty-state--${this.variant}`
];
return classes.join(' ');
}
handleAction(event: MouseEvent): void {
if (!this.disabled) {
this.action.emit(event);
}
}
}

View File

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

View File

@@ -0,0 +1,9 @@
export * from "./alert";
export * from "./status-badge.component";
export * from "./skeleton-loader";
export * from "./empty-state";
export * from "./loading-spinner";
export * from "./progress-circle";
export * from "./snackbar";
export * from "./toast";
// export * from "./theme-switcher.component"; // Temporarily disabled due to CSS variable issues

View File

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

View File

@@ -0,0 +1,248 @@
@use 'ui-design-system/src/styles/semantic' as *;
.ui-loading-spinner {
// Core Structure
display: inline-flex;
align-items: center;
justify-content: center;
position: relative;
flex-shrink: 0;
// Sizing Variants
&--sm {
width: $semantic-sizing-icon-inline;
height: $semantic-sizing-icon-inline;
}
&--md {
width: $semantic-sizing-icon-button;
height: $semantic-sizing-icon-button;
}
&--lg {
width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
}
// Color Variants
&--primary {
color: $semantic-color-brand-primary;
}
&--secondary {
color: $semantic-color-brand-secondary;
}
&--accent {
color: $semantic-color-brand-accent;
}
&--success {
color: $semantic-color-success;
}
&--warning {
color: $semantic-color-warning;
}
&--danger {
color: $semantic-color-danger;
}
&--info {
color: $semantic-color-info;
}
// Style Variants
&--spin .ui-loading-spinner__spin {
border: 2px solid $semantic-color-border-secondary;
border-top: 2px solid currentColor;
border-radius: 50%;
width: 100%;
height: 100%;
animation: ui-spinner-spin 1s ease-in-out infinite;
}
&--dots {
.ui-loading-spinner__dots {
display: flex;
gap: 0.25rem;
.ui-loading-spinner__dot {
width: 25%;
height: 25%;
background-color: currentColor;
border-radius: 50%;
animation: ui-spinner-dots 0.6s ease-in-out infinite;
&:nth-child(1) { animation-delay: 0ms; }
&:nth-child(2) { animation-delay: 150ms; }
&:nth-child(3) { animation-delay: 300ms; }
}
}
}
&--pulse .ui-loading-spinner__pulse {
width: 100%;
height: 100%;
background-color: currentColor;
border-radius: 50%;
animation: ui-spinner-pulse 1s ease-in-out infinite;
}
&--bars {
.ui-loading-spinner__bars {
display: flex;
align-items: flex-end;
gap: 20%;
height: 100%;
.ui-loading-spinner__bar {
width: 15%;
background-color: currentColor;
border-radius: 0.125rem;
animation: ui-spinner-bars 0.6s ease-in-out infinite;
&:nth-child(1) { animation-delay: 0ms; }
&:nth-child(2) { animation-delay: 100ms; }
&:nth-child(3) { animation-delay: 200ms; }
}
}
}
// State Variants
&--disabled {
opacity: 0.38;
pointer-events: none;
}
// Speed Variants
&--slow {
.ui-loading-spinner__spin {
animation-duration: 2s;
}
.ui-loading-spinner__dots .ui-loading-spinner__dot {
animation-duration: 1.2s;
}
.ui-loading-spinner__pulse {
animation-duration: 2s;
}
.ui-loading-spinner__bars .ui-loading-spinner__bar {
animation-duration: 1.2s;
}
}
&--fast {
.ui-loading-spinner__spin {
animation-duration: 0.5s;
}
.ui-loading-spinner__dots .ui-loading-spinner__dot {
animation-duration: 0.3s;
}
.ui-loading-spinner__pulse {
animation-duration: 0.5s;
}
.ui-loading-spinner__bars .ui-loading-spinner__bar {
animation-duration: 0.3s;
}
}
// Label
&__label {
margin-left: 0.5rem;
color: $semantic-color-text-secondary;
font-size: 0.875rem;
font-weight: 500;
}
// Screen Reader Only Text
&__sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
// Accessibility - Respect reduced motion preference
@media (prefers-reduced-motion: reduce) {
.ui-loading-spinner__spin,
.ui-loading-spinner__dots .ui-loading-spinner__dot,
.ui-loading-spinner__pulse,
.ui-loading-spinner__bars .ui-loading-spinner__bar {
animation: none;
}
.ui-loading-spinner__spin {
border-top-color: transparent;
border-right-color: transparent;
border-bottom-color: transparent;
}
.ui-loading-spinner__pulse {
opacity: 0.6;
}
}
// High contrast mode
@media (prefers-contrast: high) {
.ui-loading-spinner__spin {
border-width: 3px;
}
}
}
// Keyframes
@keyframes ui-spinner-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
@keyframes ui-spinner-dots {
0%, 20%, 80%, 100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.5);
opacity: 0.7;
}
}
@keyframes ui-spinner-pulse {
0% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.5;
}
100% {
transform: scale(1);
opacity: 1;
}
}
@keyframes ui-spinner-bars {
0%, 40%, 100% {
height: 40%;
}
20% {
height: 100%;
}
}

View File

@@ -0,0 +1,123 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
type SpinnerSize = 'sm' | 'md' | 'lg';
type SpinnerVariant = 'primary' | 'secondary' | 'accent' | 'success' | 'warning' | 'danger' | 'info';
type SpinnerType = 'spin' | 'dots' | 'pulse' | 'bars';
type SpinnerSpeed = 'slow' | 'normal' | 'fast';
@Component({
selector: 'ui-loading-spinner',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-loading-spinner"
[class]="getComponentClasses()"
[attr.aria-label]="ariaLabel || defaultAriaLabel"
[attr.aria-busy]="!disabled"
[attr.role]="role"
[tabindex]="disabled ? -1 : tabIndex"
(click)="handleClick($event)"
(keydown)="handleKeydown($event)">
<!-- Screen reader text -->
<span class="ui-loading-spinner__sr-only">
{{ srText || defaultSrText }}
</span>
<!-- Spinner variants -->
@switch (type) {
@case ('spin') {
<div class="ui-loading-spinner__spin"></div>
}
@case ('dots') {
<div class="ui-loading-spinner__dots">
<div class="ui-loading-spinner__dot"></div>
<div class="ui-loading-spinner__dot"></div>
<div class="ui-loading-spinner__dot"></div>
</div>
}
@case ('pulse') {
<div class="ui-loading-spinner__pulse"></div>
}
@case ('bars') {
<div class="ui-loading-spinner__bars">
<div class="ui-loading-spinner__bar"></div>
<div class="ui-loading-spinner__bar"></div>
<div class="ui-loading-spinner__bar"></div>
</div>
}
}
<!-- Optional label -->
@if (label && showLabel) {
<span class="ui-loading-spinner__label">{{ label }}</span>
}
</div>
`,
styleUrl: './loading-spinner.component.scss'
})
export class LoadingSpinnerComponent {
@Input() size: SpinnerSize = 'md';
@Input() variant: SpinnerVariant = 'primary';
@Input() type: SpinnerType = 'spin';
@Input() speed: SpinnerSpeed = 'normal';
@Input() disabled = false;
@Input() label = '';
@Input() showLabel = false;
@Input() role = 'status';
@Input() tabIndex = 0;
@Input() ariaLabel = '';
@Input() srText = '';
@Output() clicked = new EventEmitter<MouseEvent>();
getComponentClasses(): string {
const classes = [
'ui-loading-spinner',
`ui-loading-spinner--${this.size}`,
`ui-loading-spinner--${this.variant}`,
`ui-loading-spinner--${this.type}`
];
if (this.speed !== 'normal') {
classes.push(`ui-loading-spinner--${this.speed}`);
}
if (this.disabled) {
classes.push('ui-loading-spinner--disabled');
}
return classes.join(' ');
}
get defaultAriaLabel(): string {
if (this.ariaLabel) return this.ariaLabel;
return this.label ? `Loading: ${this.label}` : 'Loading';
}
get defaultSrText(): string {
if (this.srText) return this.srText;
return this.label ? `Loading ${this.label}...` : 'Loading...';
}
handleClick(event: MouseEvent): void {
if (!this.disabled) {
this.clicked.emit(event);
}
}
handleKeydown(event: KeyboardEvent): void {
// Allow keyboard interaction if the spinner is interactive
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.handleClick(event as any);
}
}
}

View File

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

View File

@@ -0,0 +1,198 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-progress-circle {
// Core Structure
display: inline-flex;
position: relative;
align-items: center;
justify-content: center;
// Sizing variants
&--sm {
width: $semantic-sizing-icon-button;
height: $semantic-sizing-icon-button;
}
&--md {
width: $semantic-sizing-icon-navigation;
height: $semantic-sizing-icon-navigation;
}
&--lg {
width: $semantic-sizing-icon-header;
height: $semantic-sizing-icon-header;
}
&--xl {
width: $semantic-sizing-icon-feature;
height: $semantic-sizing-icon-feature;
}
// Color variants
&--primary {
.ui-progress-circle__progress {
stroke: $semantic-color-primary;
}
}
&--secondary {
.ui-progress-circle__progress {
stroke: $semantic-color-secondary;
}
}
&--success {
.ui-progress-circle__progress {
stroke: $semantic-color-success;
}
}
&--warning {
.ui-progress-circle__progress {
stroke: $semantic-color-warning;
}
}
&--danger {
.ui-progress-circle__progress {
stroke: $semantic-color-danger;
}
}
&--info {
.ui-progress-circle__progress {
stroke: $semantic-color-info;
}
}
// State variants
&--disabled {
opacity: 0.38;
cursor: not-allowed;
}
&--indeterminate {
.ui-progress-circle__progress {
animation: progress-circle-spin $semantic-motion-duration-slow
$semantic-easing-standard infinite linear;
stroke-dasharray: 85, 85;
stroke-dashoffset: 0;
}
}
// SVG elements
&__svg {
width: 100%;
height: 100%;
transform: rotate(-90deg); // Start progress from top
overflow: visible;
}
&__track {
fill: none;
stroke: $semantic-color-border-subtle;
stroke-width: 2;
}
&__progress {
fill: none;
stroke: $semantic-color-primary;
stroke-width: 2;
stroke-linecap: round;
transition: stroke-dashoffset $semantic-motion-duration-normal
$semantic-easing-standard;
}
// Label content
&__label {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: $semantic-color-text-primary;
font-size: $semantic-typography-font-size-xs;
font-weight: $semantic-typography-font-weight-medium;
text-align: center;
line-height: 1;
&--sm {
font-size: $semantic-typography-font-size-xs;
}
&--md {
font-size: $semantic-typography-font-size-xs;
}
&--lg {
font-size: $semantic-typography-font-size-sm;
}
&--xl {
font-size: $semantic-typography-font-size-sm;
}
&--disabled {
opacity: 0.38;
}
}
// Stroke width variants
&--thin {
.ui-progress-circle__track,
.ui-progress-circle__progress {
stroke-width: 1;
}
}
&--thick {
.ui-progress-circle__track,
.ui-progress-circle__progress {
stroke-width: 3;
}
}
&--extra-thick {
.ui-progress-circle__track,
.ui-progress-circle__progress {
stroke-width: 4;
}
}
// Dark mode support
:host-context(.dark-theme) & {
.ui-progress-circle__track {
stroke: $semantic-color-border-primary;
}
.ui-progress-circle__label {
color: $semantic-color-text-primary;
}
}
}
// Keyframe animations
@keyframes progress-circle-spin {
0% {
transform: rotate(0deg);
}
50% {
transform: rotate(180deg);
stroke-dasharray: 1, 200;
stroke-dashoffset: -35;
}
100% {
transform: rotate(360deg);
stroke-dasharray: 85, 85;
stroke-dashoffset: -124;
}
}
// Responsive design
@media (max-width: 480px) {
.ui-progress-circle {
&--xl {
width: $semantic-sizing-icon-header;
height: $semantic-sizing-icon-header;
}
}
}

View File

@@ -0,0 +1,167 @@
import { Component, Input, ChangeDetectionStrategy, computed, signal, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
export type ProgressCircleSize = 'sm' | 'md' | 'lg' | 'xl';
export type ProgressCircleVariant = 'primary' | 'secondary' | 'success' | 'warning' | 'danger' | 'info';
export type ProgressCircleStroke = 'thin' | 'default' | 'thick' | 'extra-thick';
@Component({
selector: 'ui-progress-circle',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-progress-circle"
[class]="containerClasses()"
[attr.aria-label]="ariaLabel || 'Progress indicator'"
[attr.aria-valuenow]="indeterminate ? null : value"
[attr.aria-valuemin]="indeterminate ? null : 0"
[attr.aria-valuemax]="indeterminate ? null : max"
[attr.aria-valuetext]="ariaValueText"
[attr.role]="'progressbar'">
<svg
class="ui-progress-circle__svg"
[attr.viewBox]="viewBox"
[attr.width]="svgSize()"
[attr.height]="svgSize()">
<!-- Background track -->
<circle
class="ui-progress-circle__track"
[attr.cx]="center"
[attr.cy]="center"
[attr.r]="radius"
[attr.stroke-width]="strokeWidth" />
<!-- Progress circle -->
@if (!indeterminate) {
<circle
class="ui-progress-circle__progress"
[attr.cx]="center"
[attr.cy]="center"
[attr.r]="radius"
[attr.stroke-width]="strokeWidth"
[attr.stroke-dasharray]="circumference"
[attr.stroke-dashoffset]="progressOffset()" />
} @else {
<circle
class="ui-progress-circle__progress"
[attr.cx]="center"
[attr.cy]="center"
[attr.r]="radius"
[attr.stroke-width]="strokeWidth" />
}
</svg>
<!-- Label content -->
@if (showLabel) {
<div class="ui-progress-circle__label" [class]="labelClasses()">
@if (labelContent) {
{{ labelContent }}
} @else {
<ng-content></ng-content>
}
</div>
}
</div>
`,
styleUrl: './progress-circle.component.scss'
})
export class ProgressCircleComponent {
@Input() value = 0;
@Input() max = 100;
@Input() size: ProgressCircleSize = 'md';
@Input() variant: ProgressCircleVariant = 'primary';
@Input() stroke: ProgressCircleStroke = 'default';
@Input() disabled = false;
@Input() indeterminate = false;
@Input() showLabel = false;
@Input() labelContent = '';
@Input() ariaLabel = '';
@Input() ariaValueText = '';
// Constants for SVG calculations
private readonly _center = 20;
private readonly _baseRadius = 16;
// Computed properties for SVG dimensions
center = this._center;
radius = this._baseRadius;
viewBox = `0 0 ${this._center * 2} ${this._center * 2}`;
// Computed signals
private _normalizedValue = computed(() => {
const val = Math.max(0, Math.min(this.max, this.value));
return val;
});
private _percentage = computed(() => {
return this.max === 0 ? 0 : (this._normalizedValue() / this.max) * 100;
});
circumference = computed(() => 2 * Math.PI * this.radius);
progressOffset = computed(() => {
const progress = this._percentage();
return this.circumference() - (progress / 100) * this.circumference();
});
svgSize = computed(() => {
const sizeMap = {
sm: 24,
md: 32,
lg: 40,
xl: 64
};
return sizeMap[this.size];
});
strokeWidth = computed(() => {
const strokeMap = {
thin: 1,
default: 2,
thick: 3,
'extra-thick': 4
};
return strokeMap[this.stroke];
});
containerClasses = computed(() => {
const classes = [
`ui-progress-circle--${this.size}`,
`ui-progress-circle--${this.variant}`,
`ui-progress-circle--${this.stroke}`
];
if (this.disabled) classes.push('ui-progress-circle--disabled');
if (this.indeterminate) classes.push('ui-progress-circle--indeterminate');
return classes.join(' ');
});
labelClasses = computed(() => {
const classes = [
`ui-progress-circle__label--${this.size}`
];
if (this.disabled) classes.push('ui-progress-circle__label--disabled');
return classes.join(' ');
});
// Utility methods
getPercentage(): number {
return this._percentage();
}
getValue(): number {
return this._normalizedValue();
}
isComplete(): boolean {
return this._normalizedValue() >= this.max;
}
}

View File

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

View File

@@ -0,0 +1,293 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-skeleton-loader {
display: block;
width: 100%;
overflow: hidden;
position: relative;
background: $semantic-color-surface-variant;
// Core structure
&::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.2),
transparent
);
animation: shimmer 2s ease-in-out infinite;
transform: translateX(-100%);
}
// Size variants
&--xs {
height: 0.5rem;
border-radius: 0.125rem;
}
&--sm {
height: 0.75rem;
border-radius: 0.25rem;
}
&--md {
height: 1rem;
border-radius: 0.375rem;
}
&--lg {
height: 1.25rem;
border-radius: 0.5rem;
}
&--xl {
height: 1.5rem;
border-radius: 0.5rem;
}
// Shape variants
&--text {
height: 1.25rem;
border-radius: 0.25rem;
}
&--heading {
height: 1.75rem;
border-radius: 0.25rem;
}
&--avatar {
border-radius: 50%;
aspect-ratio: 1;
&.ui-skeleton-loader--xs { width: 1rem; }
&.ui-skeleton-loader--sm { width: 1.5rem; }
&.ui-skeleton-loader--md { width: 2rem; }
&.ui-skeleton-loader--lg { width: 2.5rem; }
&.ui-skeleton-loader--xl { width: 3rem; }
}
&--card {
height: 200px;
border-radius: 0.5rem;
}
&--button {
height: 2.5rem;
width: 120px;
border-radius: 0.375rem;
}
&--image {
aspect-ratio: 16/9;
border-radius: 0.375rem;
}
&--circle {
border-radius: 50%;
aspect-ratio: 1;
}
&--rounded {
border-radius: 0.375rem;
}
&--square {
border-radius: 0;
}
// Animation variants
&--pulse {
&::after {
display: none;
}
animation: pulse 1.5s ease-in-out infinite alternate;
}
&--wave {
&::after {
background: linear-gradient(
90deg,
transparent 0%,
rgba(255, 255, 255, 0.4) 50%,
transparent 100%
);
animation: wave 2s ease-in-out infinite;
}
}
&--shimmer {
// Default shimmer animation (already applied)
}
// Width variants
&--w-25 { width: 25%; }
&--w-50 { width: 50%; }
&--w-75 { width: 75%; }
&--w-full { width: 100%; }
// State variants
&--loading {
opacity: 1;
}
&--loaded {
opacity: 0;
transition: opacity 0.3s ease;
}
// Dark mode support
:host-context(.dark-theme) & {
background: #374151;
&::after {
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.1),
transparent
);
}
}
// High contrast mode
@media (prefers-contrast: high) {
background: #6b7280;
&::after {
background: linear-gradient(
90deg,
transparent,
rgba(255, 255, 255, 0.6),
transparent
);
}
}
// Reduced motion
@media (prefers-reduced-motion: reduce) {
&::after {
animation: none;
}
&.ui-skeleton-loader--pulse {
animation: none;
}
}
}
// Skeleton group for multiple skeleton items
.ui-skeleton-group {
display: flex;
flex-direction: column;
gap: 0.75rem;
// Horizontal group
&--horizontal {
flex-direction: row;
align-items: center;
}
// Grid group
&--grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1rem;
}
// Avatar with text group
&--avatar-text {
flex-direction: row;
align-items: flex-start;
gap: 0.75rem;
.ui-skeleton-loader--avatar {
flex-shrink: 0;
}
.ui-skeleton-content {
flex: 1;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
}
// Card group
&--card {
.ui-skeleton-loader {
&:first-child {
height: 180px;
border-radius: 0.5rem 0.5rem 0 0;
}
&:not(:first-child) {
margin: 0.75rem;
height: 1rem;
&:last-child {
width: 60%;
}
}
}
}
// Table group
&--table {
.ui-skeleton-loader {
height: 1.25rem;
&:first-child {
width: 30%;
}
&:nth-child(2) {
width: 50%;
}
&:last-child {
width: 20%;
}
}
}
}
// Animations
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
@keyframes pulse {
0% {
opacity: 1;
}
100% {
opacity: 0.6;
}
}
@keyframes wave {
0% {
transform: translateX(-100%) scale(0.8);
opacity: 0;
}
50% {
opacity: 1;
}
100% {
transform: translateX(100%) scale(1.2);
opacity: 0;
}
}

View File

@@ -0,0 +1,347 @@
import { Component, Input, ChangeDetectionStrategy, ViewEncapsulation } from '@angular/core';
import { CommonModule } from '@angular/common';
export type SkeletonSize = 'xs' | 'sm' | 'md' | 'lg' | 'xl';
export type SkeletonShape = 'text' | 'heading' | 'avatar' | 'card' | 'button' | 'image' | 'circle' | 'rounded' | 'square';
export type SkeletonAnimation = 'shimmer' | 'pulse' | 'wave';
export type SkeletonWidth = 'w-25' | 'w-50' | 'w-75' | 'w-full' | 'auto';
@Component({
selector: 'ui-skeleton-loader',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-skeleton-loader"
[class]="skeletonClasses"
[style.width]="customWidth"
[style.height]="customHeight"
[attr.aria-label]="ariaLabel"
[attr.aria-busy]="loading"
[attr.role]="role"
[attr.data-testid]="'skeleton-' + shape + '-' + size">
</div>
`,
styleUrl: './skeleton-loader.component.scss'
})
export class SkeletonLoaderComponent {
@Input() size: SkeletonSize = 'md';
@Input() shape: SkeletonShape = 'text';
@Input() animation: SkeletonAnimation = 'shimmer';
@Input() width: SkeletonWidth = 'w-full';
@Input() loading = true;
@Input() customWidth?: string;
@Input() customHeight?: string;
@Input() rounded = false;
@Input() circle = false;
@Input() ariaLabel = 'Loading content';
@Input() role = 'status';
@Input() class = '';
get skeletonClasses(): string {
const classes = [
'ui-skeleton-loader',
`ui-skeleton-loader--${this.size}`,
`ui-skeleton-loader--${this.shape}`,
`ui-skeleton-loader--${this.animation}`,
this.width !== 'auto' ? `ui-skeleton-loader--${this.width}` : '',
this.loading ? 'ui-skeleton-loader--loading' : 'ui-skeleton-loader--loaded',
this.rounded ? 'ui-skeleton-loader--rounded' : '',
this.circle ? 'ui-skeleton-loader--circle' : '',
this.class
].filter(Boolean);
return classes.join(' ');
}
}
@Component({
selector: 'ui-skeleton-group',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-skeleton-group"
[class]="groupClasses"
[attr.aria-label]="ariaLabel"
[attr.role]="role">
<ng-content></ng-content>
</div>
`,
styleUrl: './skeleton-loader.component.scss'
})
export class SkeletonGroupComponent {
@Input() variant: 'vertical' | 'horizontal' | 'grid' | 'avatar-text' | 'card' | 'table' = 'vertical';
@Input() ariaLabel = 'Loading multiple items';
@Input() role = 'status';
@Input() class = '';
get groupClasses(): string {
const classes = [
'ui-skeleton-group',
this.variant !== 'vertical' ? `ui-skeleton-group--${this.variant}` : '',
this.class
].filter(Boolean);
return classes.join(' ');
}
}
// Pre-built skeleton patterns for common use cases
@Component({
selector: 'ui-skeleton-text-block',
standalone: true,
imports: [CommonModule, SkeletonLoaderComponent, SkeletonGroupComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ui-skeleton-group>
@for (line of lineArray; track $index) {
<ui-skeleton-loader
shape="text"
[size]="size"
[width]="getLineWidth($index)"
[animation]="animation">
</ui-skeleton-loader>
}
</ui-skeleton-group>
`
})
export class SkeletonTextBlockComponent {
@Input() lines = 3;
@Input() size: SkeletonSize = 'md';
@Input() animation: SkeletonAnimation = 'shimmer';
@Input() lastLineWidth: SkeletonWidth = 'w-75';
get lineArray(): number[] {
return Array.from({ length: this.lines }, (_, i) => i);
}
getLineWidth(index: number): SkeletonWidth {
return index === this.lines - 1 ? this.lastLineWidth : 'w-full';
}
}
@Component({
selector: 'ui-skeleton-article',
standalone: true,
imports: [CommonModule, SkeletonLoaderComponent, SkeletonGroupComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ui-skeleton-group>
<!-- Article title -->
<ui-skeleton-loader
shape="heading"
size="lg"
width="w-75"
[animation]="animation">
</ui-skeleton-loader>
<!-- Article meta -->
<ui-skeleton-loader
shape="text"
size="sm"
width="w-50"
[animation]="animation">
</ui-skeleton-loader>
<!-- Article image -->
@if (showImage) {
<ui-skeleton-loader
shape="image"
[animation]="animation">
</ui-skeleton-loader>
}
<!-- Article content -->
@for (line of contentArray; track $index) {
<ui-skeleton-loader
shape="text"
[width]="getContentLineWidth($index)"
[animation]="animation">
</ui-skeleton-loader>
}
</ui-skeleton-group>
`
})
export class SkeletonArticleComponent {
@Input() showImage = true;
@Input() contentLines = 5;
@Input() animation: SkeletonAnimation = 'shimmer';
get contentArray(): number[] {
return Array.from({ length: this.contentLines }, (_, i) => i);
}
getContentLineWidth(index: number): SkeletonWidth {
if (index === this.contentLines - 1) return 'w-50';
if (index === this.contentLines - 2) return 'w-75';
return 'w-full';
}
}
@Component({
selector: 'ui-skeleton-card',
standalone: true,
imports: [CommonModule, SkeletonLoaderComponent, SkeletonGroupComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ui-skeleton-group variant="card">
<!-- Card image -->
@if (showImage) {
<ui-skeleton-loader
shape="image"
[animation]="animation">
</ui-skeleton-loader>
}
<!-- Card title -->
<ui-skeleton-loader
shape="heading"
size="md"
width="w-75"
[animation]="animation">
</ui-skeleton-loader>
<!-- Card content -->
@for (line of contentArray; track $index) {
<ui-skeleton-loader
shape="text"
[width]="getCardLineWidth($index)"
[animation]="animation">
</ui-skeleton-loader>
}
<!-- Card actions -->
@if (showActions) {
<ui-skeleton-group variant="horizontal">
<ui-skeleton-loader
shape="button"
size="sm"
width="auto"
customWidth="80px"
[animation]="animation">
</ui-skeleton-loader>
<ui-skeleton-loader
shape="button"
size="sm"
width="auto"
customWidth="80px"
[animation]="animation">
</ui-skeleton-loader>
</ui-skeleton-group>
}
</ui-skeleton-group>
`
})
export class SkeletonCardComponent {
@Input() showImage = true;
@Input() showActions = true;
@Input() contentLines = 3;
@Input() animation: SkeletonAnimation = 'shimmer';
get contentArray(): number[] {
return Array.from({ length: this.contentLines }, (_, i) => i);
}
getCardLineWidth(index: number): SkeletonWidth {
if (index === this.contentLines - 1) return 'w-50';
return 'w-full';
}
}
@Component({
selector: 'ui-skeleton-profile',
standalone: true,
imports: [CommonModule, SkeletonLoaderComponent, SkeletonGroupComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ui-skeleton-group variant="avatar-text">
<!-- Profile avatar -->
<ui-skeleton-loader
shape="avatar"
[size]="avatarSize"
[animation]="animation">
</ui-skeleton-loader>
<!-- Profile info -->
<div class="ui-skeleton-content">
<!-- Name -->
<ui-skeleton-loader
shape="text"
size="lg"
width="w-50"
[animation]="animation">
</ui-skeleton-loader>
<!-- Role/Title -->
<ui-skeleton-loader
shape="text"
size="sm"
width="w-75"
[animation]="animation">
</ui-skeleton-loader>
<!-- Additional info -->
@if (showExtraInfo) {
<ui-skeleton-loader
shape="text"
size="sm"
width="w-25"
[animation]="animation">
</ui-skeleton-loader>
}
</div>
</ui-skeleton-group>
`
})
export class SkeletonProfileComponent {
@Input() avatarSize: SkeletonSize = 'lg';
@Input() showExtraInfo = true;
@Input() animation: SkeletonAnimation = 'shimmer';
}
@Component({
selector: 'ui-skeleton-table-row',
standalone: true,
imports: [CommonModule, SkeletonLoaderComponent, SkeletonGroupComponent],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<ui-skeleton-group variant="table">
@for (column of columnArray; track $index) {
<ui-skeleton-loader
shape="text"
[width]="getColumnWidth($index)"
[animation]="animation">
</ui-skeleton-loader>
}
</ui-skeleton-group>
`
})
export class SkeletonTableRowComponent {
@Input() columns = 3;
@Input() animation: SkeletonAnimation = 'shimmer';
get columnArray(): number[] {
return Array.from({ length: this.columns }, (_, i) => i);
}
getColumnWidth(index: number): SkeletonWidth {
const widths: SkeletonWidth[] = ['w-25', 'w-50', 'w-25', 'w-full'];
return widths[index % widths.length];
}
}
// Convenience export for all skeleton components
export const SKELETON_COMPONENTS = [
SkeletonLoaderComponent,
SkeletonGroupComponent,
SkeletonTextBlockComponent,
SkeletonArticleComponent,
SkeletonCardComponent,
SkeletonProfileComponent,
SkeletonTableRowComponent
] as const;

View File

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

View File

@@ -0,0 +1,235 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-snackbar {
// Core Structure
display: flex;
align-items: center;
position: fixed;
bottom: $semantic-spacing-layout-section-md;
left: 50%;
transform: translateX(-50%);
z-index: $semantic-z-index-modal;
max-width: 90vw;
width: auto;
min-width: 300px;
// Layout & Spacing
padding: $semantic-spacing-component-md $semantic-spacing-component-lg;
gap: $semantic-spacing-component-sm;
// Visual Design
background: $semantic-color-surface-elevated;
border: $semantic-border-width-1 solid $semantic-color-border-secondary;
border-radius: $semantic-border-radius-lg;
box-shadow: $semantic-shadow-elevation-4;
// Typography
font-family: map-get($semantic-typography-body-medium, font-family);
font-size: map-get($semantic-typography-body-medium, font-size);
font-weight: map-get($semantic-typography-body-medium, font-weight);
line-height: map-get($semantic-typography-body-medium, line-height);
color: $semantic-color-text-primary;
// Transitions
transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease;
// Size Variants
&--sm {
min-height: $semantic-sizing-button-height-sm;
padding: $semantic-spacing-component-xs $semantic-spacing-component-md;
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
}
&--md {
min-height: $semantic-sizing-button-height-md;
padding: $semantic-spacing-component-md $semantic-spacing-component-lg;
}
&--lg {
min-height: $semantic-sizing-button-height-lg;
padding: $semantic-spacing-component-lg;
font-family: map-get($semantic-typography-body-large, font-family);
font-size: map-get($semantic-typography-body-large, font-size);
font-weight: map-get($semantic-typography-body-large, font-weight);
line-height: map-get($semantic-typography-body-large, line-height);
}
// Color Variants
&--primary {
background: $semantic-color-primary;
color: $semantic-color-on-primary;
border-color: $semantic-color-primary;
}
&--success {
background: $semantic-color-success;
color: $semantic-color-on-success;
border-color: $semantic-color-success;
}
&--warning {
background: $semantic-color-warning;
color: $semantic-color-on-warning;
border-color: $semantic-color-warning;
}
&--danger {
background: $semantic-color-danger;
color: $semantic-color-on-danger;
border-color: $semantic-color-danger;
}
&--info {
background: $semantic-color-info;
color: $semantic-color-on-info;
border-color: $semantic-color-info;
}
// Position Variants
&--top {
top: $semantic-spacing-layout-section-md;
bottom: auto;
}
&--left {
left: $semantic-spacing-layout-section-md;
transform: translateX(0);
}
&--right {
right: $semantic-spacing-layout-section-md;
left: auto;
transform: translateX(0);
}
// Animation States
&--entering {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
&--exiting {
opacity: 0;
transform: translateX(-50%) translateY(20px);
}
// BEM Elements
&__content {
flex: 1;
min-width: 0;
&--multiline {
padding: $semantic-spacing-content-line-tight 0;
}
}
&__message {
color: inherit;
margin: 0;
overflow-wrap: break-word;
}
&__actions {
display: flex;
align-items: center;
gap: $semantic-spacing-component-xs;
margin-left: $semantic-spacing-component-sm;
flex-shrink: 0;
}
&__action {
background: transparent;
border: none;
padding: $semantic-spacing-component-xs;
border-radius: $semantic-border-radius-sm;
cursor: pointer;
transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease;
// Use inherited text color with opacity for actions
color: inherit;
font-weight: $semantic-typography-font-weight-medium;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
&:active {
background-color: rgba(255, 255, 255, 0.2);
}
}
&__dismiss {
background: transparent;
border: none;
padding: $semantic-spacing-component-xs;
border-radius: $semantic-border-radius-sm;
cursor: pointer;
margin-left: $semantic-spacing-component-sm;
color: inherit;
display: flex;
align-items: center;
justify-content: center;
transition: background-color $semantic-motion-duration-fast $semantic-motion-easing-ease;
&:hover {
background-color: rgba(255, 255, 255, 0.1);
}
&:focus-visible {
outline: 2px solid $semantic-color-focus;
outline-offset: 2px;
}
&:active {
background-color: rgba(255, 255, 255, 0.2);
}
}
// Icon styling
&__icon {
flex-shrink: 0;
color: inherit;
font-size: $semantic-sizing-icon-inline;
}
// Responsive Design
@media (max-width: $semantic-breakpoint-md - 1) {
bottom: $semantic-spacing-layout-section-sm;
left: $semantic-spacing-layout-section-sm;
right: $semantic-spacing-layout-section-sm;
transform: translateX(0);
max-width: none;
&--left,
&--right {
left: $semantic-spacing-layout-section-sm;
right: $semantic-spacing-layout-section-sm;
transform: translateX(0);
}
}
@media (max-width: $semantic-breakpoint-sm - 1) {
padding: $semantic-spacing-component-sm;
bottom: $semantic-spacing-layout-section-xs;
left: $semantic-spacing-layout-section-xs;
right: $semantic-spacing-layout-section-xs;
font-family: map-get($semantic-typography-body-small, font-family);
font-size: map-get($semantic-typography-body-small, font-size);
font-weight: map-get($semantic-typography-body-small, font-weight);
line-height: map-get($semantic-typography-body-small, line-height);
&__actions {
margin-left: $semantic-spacing-component-xs;
gap: $semantic-spacing-component-xs;
}
}
}

View File

@@ -0,0 +1,243 @@
import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnDestroy, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FontAwesomeModule } from '@fortawesome/angular-fontawesome';
import { IconDefinition } from '@fortawesome/free-solid-svg-icons';
import {
faCheckCircle,
faExclamationTriangle,
faExclamationCircle,
faInfoCircle,
faTimes
} from '@fortawesome/free-solid-svg-icons';
type SnackbarSize = 'sm' | 'md' | 'lg';
type SnackbarVariant = 'default' | 'primary' | 'success' | 'warning' | 'danger' | 'info';
type SnackbarPosition = 'bottom-center' | 'bottom-left' | 'bottom-right' | 'top-center' | 'top-left' | 'top-right';
export interface SnackbarAction {
label: string;
handler: () => void;
disabled?: boolean;
}
@Component({
selector: 'ui-snackbar',
standalone: true,
imports: [CommonModule, FontAwesomeModule],
changeDetection: ChangeDetectionStrategy.Default,
encapsulation: ViewEncapsulation.None,
template: `
<div
class="ui-snackbar"
[class.ui-snackbar--sm]="size === 'sm'"
[class.ui-snackbar--md]="size === 'md'"
[class.ui-snackbar--lg]="size === 'lg'"
[class.ui-snackbar--primary]="variant === 'primary'"
[class.ui-snackbar--success]="variant === 'success'"
[class.ui-snackbar--warning]="variant === 'warning'"
[class.ui-snackbar--danger]="variant === 'danger'"
[class.ui-snackbar--info]="variant === 'info'"
[class.ui-snackbar--top]="position.startsWith('top')"
[class.ui-snackbar--left]="position.includes('left')"
[class.ui-snackbar--right]="position.includes('right')"
[class.ui-snackbar--entering]="isEntering"
[class.ui-snackbar--exiting]="isExiting"
[attr.role]="role"
[attr.aria-live]="ariaLive"
[attr.aria-labelledby]="message ? 'snackbar-message-' + snackbarId : null">
@if (showIcon && snackbarIcon) {
<fa-icon
class="ui-snackbar__icon"
[icon]="snackbarIcon"
[attr.aria-hidden]="true">
</fa-icon>
}
<div class="ui-snackbar__content" [class.ui-snackbar__content--multiline]="isMultiline">
<div
class="ui-snackbar__message"
[id]="'snackbar-message-' + snackbarId">
{{ message }}
<ng-content></ng-content>
</div>
</div>
@if (actions && actions.length > 0) {
<div class="ui-snackbar__actions">
@for (action of actions; track action.label) {
<button
type="button"
class="ui-snackbar__action"
[disabled]="action.disabled"
[attr.aria-label]="action.label"
(click)="handleActionClick(action)">
{{ action.label }}
</button>
}
</div>
}
@if (dismissible) {
<button
type="button"
class="ui-snackbar__dismiss"
[attr.aria-label]="dismissLabel"
(click)="handleDismiss()"
(keydown)="handleDismissKeydown($event)">
<fa-icon [icon]="faTimes" [attr.aria-hidden]="true"></fa-icon>
</button>
}
</div>
`,
styleUrl: './snackbar.component.scss'
})
export class SnackbarComponent implements OnInit, OnDestroy {
@Input() size: SnackbarSize = 'md';
@Input() variant: SnackbarVariant = 'default';
@Input() message = '';
@Input() showIcon = false;
@Input() dismissible = true;
@Input() autoDismiss = true;
@Input() duration = 4000; // 4 seconds - shorter than toast
@Input() position: SnackbarPosition = 'bottom-center';
@Input() dismissLabel = 'Dismiss snackbar';
@Input() role = 'status';
@Input() ariaLive: 'polite' | 'assertive' | 'off' = 'polite';
@Input() actions: SnackbarAction[] = [];
@Input() isMultiline = false;
@Output() dismissed = new EventEmitter<void>();
@Output() expired = new EventEmitter<void>();
@Output() shown = new EventEmitter<void>();
@Output() hidden = new EventEmitter<void>();
@Output() actionClicked = new EventEmitter<SnackbarAction>();
// Icons
readonly faCheckCircle = faCheckCircle;
readonly faExclamationTriangle = faExclamationTriangle;
readonly faExclamationCircle = faExclamationCircle;
readonly faInfoCircle = faInfoCircle;
readonly faTimes = faTimes;
// Generate unique ID for accessibility
readonly snackbarId = Math.random().toString(36).substr(2, 9);
// State management
isEntering = false;
isExiting = false;
private autoDismissTimeout?: any;
private enterTimeout?: any;
private exitTimeout?: any;
get snackbarIcon(): IconDefinition | null {
if (!this.showIcon) return null;
switch (this.variant) {
case 'success':
return this.faCheckCircle;
case 'warning':
return this.faExclamationTriangle;
case 'danger':
return this.faExclamationCircle;
case 'info':
return this.faInfoCircle;
case 'primary':
case 'default':
default:
return this.faInfoCircle;
}
}
ngOnInit(): void {
this.show();
}
ngOnDestroy(): void {
this.clearTimeouts();
}
show(): void {
this.isEntering = true;
this.enterTimeout = setTimeout(() => {
this.isEntering = false;
this.shown.emit();
if (this.autoDismiss && this.duration > 0) {
this.startAutoDismissTimer();
}
}, 300); // Animation duration
}
hide(): void {
this.clearTimeouts();
this.isExiting = true;
this.exitTimeout = setTimeout(() => {
this.isExiting = false;
this.hidden.emit();
}, 300); // Animation duration
}
handleDismiss(): void {
this.hide();
this.dismissed.emit();
}
handleDismissKeydown(event: KeyboardEvent): void {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
this.handleDismiss();
}
}
handleActionClick(action: SnackbarAction): void {
if (!action.disabled) {
action.handler();
this.actionClicked.emit(action);
}
}
private startAutoDismissTimer(): void {
if (this.autoDismissTimeout) {
clearTimeout(this.autoDismissTimeout);
}
this.autoDismissTimeout = setTimeout(() => {
this.hide();
this.expired.emit();
}, this.duration);
}
private clearTimeouts(): void {
if (this.autoDismissTimeout) {
clearTimeout(this.autoDismissTimeout);
this.autoDismissTimeout = undefined;
}
if (this.enterTimeout) {
clearTimeout(this.enterTimeout);
this.enterTimeout = undefined;
}
if (this.exitTimeout) {
clearTimeout(this.exitTimeout);
this.exitTimeout = undefined;
}
}
// Pause auto-dismiss on mouse enter
onMouseEnter(): void {
if (this.autoDismissTimeout) {
clearTimeout(this.autoDismissTimeout);
}
}
// Resume auto-dismiss on mouse leave
onMouseLeave(): void {
if (this.autoDismiss && this.duration > 0 && !this.isExiting) {
this.startAutoDismissTimer();
}
}
}

View File

@@ -0,0 +1,253 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
.ui-status-badge {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-weight: 500;
line-height: 1;
text-align: center;
white-space: nowrap;
vertical-align: baseline;
transition: all 150ms cubic-bezier(0, 0, 0.2, 1);
position: relative;
// Size variants
&--small {
padding: 0.125rem 0.375rem;
font-size: 0.625rem;
min-height: 1.125rem;
&.ui-status-badge--with-icon .ui-status-badge__icon {
font-size: 0.625rem;
}
&.ui-status-badge--with-dot .ui-status-badge__dot {
width: 0.375rem;
height: 0.375rem;
}
}
&--medium {
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
min-height: 1.25rem;
&.ui-status-badge--with-icon .ui-status-badge__icon {
font-size: 0.75rem;
}
&.ui-status-badge--with-dot .ui-status-badge__dot {
width: 0.5rem;
height: 0.5rem;
}
}
&--large {
padding: 0.375rem 0.75rem;
font-size: 0.875rem;
min-height: 1.5rem;
&.ui-status-badge--with-icon .ui-status-badge__icon {
font-size: 0.875rem;
}
&.ui-status-badge--with-dot .ui-status-badge__dot {
width: 0.625rem;
height: 0.625rem;
}
}
// Shape variants
&--rounded {
border-radius: 0.25rem;
}
&--pill {
border-radius: 9999px;
}
&--square {
border-radius: 0;
}
// Style modifiers
&--uppercase {
text-transform: uppercase;
letter-spacing: 0.05em;
}
&--bold {
font-weight: 700;
}
// Success variant
&--success {
background: hsl(142, 76%, 90%);
color: hsl(142, 76%, 25%);
border: 1px solid hsl(142, 76%, 80%);
&.ui-status-badge--outlined {
background: transparent;
color: hsl(142, 76%, 35%);
border-color: hsl(142, 76%, 50%);
}
.ui-status-badge__dot {
background: hsl(142, 76%, 45%);
}
}
// Warning variant
&--warning {
background: hsl(45, 93%, 88%);
color: hsl(45, 93%, 25%);
border: 1px solid hsl(45, 93%, 75%);
&.ui-status-badge--outlined {
background: transparent;
color: hsl(45, 93%, 35%);
border-color: hsl(45, 93%, 50%);
}
.ui-status-badge__dot {
background: hsl(45, 93%, 45%);
}
}
// Danger variant
&--danger {
background: hsl(0, 84%, 90%);
color: hsl(0, 84%, 25%);
border: 1px solid hsl(0, 84%, 80%);
&.ui-status-badge--outlined {
background: transparent;
color: hsl(0, 84%, 35%);
border-color: hsl(0, 84%, 50%);
}
.ui-status-badge__dot {
background: hsl(0, 84%, 45%);
}
}
// Info variant
&--info {
background: hsl(207, 90%, 88%);
color: hsl(207, 90%, 25%);
border: 1px solid hsl(207, 90%, 75%);
&.ui-status-badge--outlined {
background: transparent;
color: hsl(207, 90%, 35%);
border-color: hsl(207, 90%, 50%);
}
.ui-status-badge__dot {
background: hsl(207, 90%, 45%);
}
}
// Neutral variant
&--neutral {
background: hsl(289, 14%, 90%);
color: hsl(279, 14%, 25%);
border: 1px solid hsl(289, 14%, 80%);
&.ui-status-badge--outlined {
background: transparent;
color: hsl(279, 14%, 35%);
border-color: hsl(289, 14%, 60%);
}
.ui-status-badge__dot {
background: hsl(287, 12%, 47%);
}
}
// Primary variant
&--primary {
background: hsl(263, 100%, 88%);
color: hsl(260, 100%, 25%);
border: 1px solid hsl(263, 100%, 75%);
&.ui-status-badge--outlined {
background: transparent;
color: hsl(258, 100%, 47%);
border-color: hsl(258, 100%, 47%);
}
.ui-status-badge__dot {
background: hsl(258, 100%, 47%);
}
}
// Secondary variant
&--secondary {
background: hsl(262, 25%, 84%);
color: hsl(256, 29%, 25%);
border: 1px solid hsl(262, 25%, 75%);
&.ui-status-badge--outlined {
background: transparent;
color: hsl(258, 29%, 40%);
border-color: hsl(258, 29%, 40%);
}
.ui-status-badge__dot {
background: hsl(258, 29%, 40%);
}
}
}
.ui-status-badge__icon {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
line-height: 1;
}
.ui-status-badge__dot {
border-radius: 50%;
flex-shrink: 0;
}
.ui-status-badge__text {
flex: 1;
min-width: 0;
}
// Hover effects for interactive badges (when used in buttons, etc.)
button .ui-status-badge,
a .ui-status-badge,
[role="button"] .ui-status-badge {
transition: all 150ms cubic-bezier(0, 0, 0.2, 1);
&:hover {
transform: scale(1.02);
}
}
// High contrast mode
@media (prefers-contrast: high) {
.ui-status-badge {
border-width: 2px;
font-weight: 700;
&--outlined {
background: buttonface;
color: buttontext;
border-color: buttontext;
}
}
}
// Reduced motion
@media (prefers-reduced-motion: reduce) {
.ui-status-badge {
transition: none;
}
}

View File

@@ -0,0 +1,54 @@
import { Component, Input, ChangeDetectionStrategy } from '@angular/core';
import { CommonModule } from '@angular/common';
export type StatusBadgeVariant = 'success' | 'warning' | 'danger' | 'info' | 'neutral' | 'primary' | 'secondary';
export type StatusBadgeSize = 'small' | 'medium' | 'large';
export type StatusBadgeShape = 'rounded' | 'pill' | 'square';
@Component({
selector: 'ui-status-badge',
standalone: true,
imports: [CommonModule],
changeDetection: ChangeDetectionStrategy.OnPush,
template: `
<span [class]="badgeClasses" [attr.aria-label]="ariaLabel">
@if (icon) {
<span class="ui-status-badge__icon" [innerHTML]="icon"></span>
}
@if (dot) {
<span class="ui-status-badge__dot"></span>
}
<span class="ui-status-badge__text">
<ng-content></ng-content>
</span>
</span>
`,
styleUrl: './status-badge.component.scss'
})
export class StatusBadgeComponent {
@Input() variant: StatusBadgeVariant = 'neutral';
@Input() size: StatusBadgeSize = 'medium';
@Input() shape: StatusBadgeShape = 'rounded';
@Input() icon: string = '';
@Input() dot: boolean = false;
@Input() outlined: boolean = false;
@Input() uppercase: boolean = true;
@Input() bold: boolean = false;
@Input() ariaLabel: string = '';
@Input() class: string = '';
get badgeClasses(): string {
return [
'ui-status-badge',
`ui-status-badge--${this.variant}`,
`ui-status-badge--${this.size}`,
`ui-status-badge--${this.shape}`,
this.outlined ? 'ui-status-badge--outlined' : '',
this.uppercase ? 'ui-status-badge--uppercase' : '',
this.bold ? 'ui-status-badge--bold' : '',
this.icon ? 'ui-status-badge--with-icon' : '',
this.dot ? 'ui-status-badge--with-dot' : '',
this.class
].filter(Boolean).join(' ');
}
}

View File

@@ -0,0 +1,579 @@
@use 'ui-design-system/src/styles/semantic/index' as *;
/**
* ==========================================================================
* THEME SWITCHER COMPONENT STYLES
* ==========================================================================
* Material Design 3 inspired styling for the theme switcher component.
* Uses CSS variables for runtime theming support.
* ==========================================================================
*/
// Tokens available globally via main application styles
.theme-switcher {
background-color: $semantic-color-surface-primary;
border-radius: $semantic-border-radius-lg;
padding: $semantic-spacing-component-lg;
box-shadow: $semantic-shadow-elevated;
border: 1px solid $semantic-color-border-primary;
position: relative;
max-width: 400px;
width: 100%;
// ==========================================================================
// HEADER STYLES
// ==========================================================================
&__header {
margin-bottom: $semantic-spacing-component-lg;
}
&__title {
@extend .skyui-heading--h3 !optional;
color: $semantic-color-text-primary;
margin: 0 0 $semantic-spacing-component-2xs 0;
}
&__subtitle {
@extend .skyui-text--small !optional;
color: $semantic-color-text-secondary;
margin: 0;
}
// ==========================================================================
// SECTION STYLES
// ==========================================================================
&__section {
margin-bottom: $semantic-spacing-component-lg;
&:last-child {
margin-bottom: 0;
}
}
&__section-title {
@extend .skyui-heading--h4 !optional;
color: $semantic-color-text-primary;
margin: 0 0 $semantic-spacing-component-sm 0;
}
&__section-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $semantic-spacing-component-sm;
}
// ==========================================================================
// DARK MODE TOGGLE STYLES
// ==========================================================================
&__option {
display: flex;
align-items: center;
}
&__label {
display: flex;
align-items: center;
cursor: pointer;
@extend .skyui-text--medium !optional;
color: $semantic-color-text-primary;
&:hover {
color: $css-color-primary;
}
}
&__checkbox {
position: absolute;
opacity: 0;
cursor: pointer;
&:checked + &-custom {
background-color: $css-color-primary;
border-color: $css-color-primary;
&::after {
display: block;
}
}
&:focus + &-custom {
outline: 2px solid $css-color-primary;
outline-offset: 2px;
}
&:disabled + &-custom {
opacity: 0.6;
cursor: not-allowed;
}
}
&__checkbox-custom {
width: 20px;
height: 20px;
border: 2px solid $css-color-outline;
border-radius: $semantic-border-radius-sm;
margin-right: $semantic-spacing-component-sm;
position: relative;
transition: all 0.2s ease;
display: flex;
align-items: center;
justify-content: center;
&::after {
content: '';
color: $css-color-on-primary;
font-size: 12px;
font-weight: bold;
display: none;
}
}
&__text {
user-select: none;
}
// ==========================================================================
// THEME GRID STYLES
// ==========================================================================
.theme-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: $semantic-spacing-component-sm;
}
.theme-card {
background-color: $semantic-color-primary-container;
border: 2px solid $semantic-color-border-primary;
border-radius: $semantic-border-radius-md;
padding: $semantic-spacing-component-sm;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
text-align: center;
&:hover:not(&--disabled) {
border-color: $css-color-primary;
box-shadow: $semantic-shadow-elevated;
transform: translateY(-1px);
}
&--active {
border-color: $css-color-primary;
background-color: $css-color-primary-container;
}
&--disabled {
opacity: 0.6;
cursor: not-allowed;
}
&__preview {
margin-bottom: $semantic-spacing-component-xs;
}
&__color-primary {
width: 100%;
height: 32px;
border-radius: $semantic-border-radius-sm;
margin-bottom: $semantic-spacing-component-2xs;
}
&__color-details {
display: flex;
gap: $semantic-spacing-component-2xs;
}
&__color-secondary,
&__color-tertiary {
flex: 1;
height: 16px;
border-radius: $semantic-border-radius-xs;
}
&__name {
@extend .skyui-text--small !optional;
color: $semantic-color-text-primary;
font-weight: $semantic-typography-font-weight-medium;
display: block;
}
&__active-indicator {
position: absolute;
top: 4px;
right: 4px;
color: $css-color-primary;
background-color: $semantic-color-surface-primary;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 12px;
}
}
// ==========================================================================
// TOGGLE BUTTON STYLES
// ==========================================================================
&__toggle-btn {
@extend .skyui-interactive-text--button-small !optional;
background-color: transparent;
color: $css-color-primary;
border: 1px solid $css-color-primary;
border-radius: $semantic-border-radius-sm;
padding: $semantic-spacing-component-xs $semantic-spacing-component-sm;
cursor: pointer;
transition: all 0.2s ease;
&:hover {
background-color: $css-color-primary-container;
border-color: $css-color-primary;
}
&--active {
background-color: $css-color-primary;
color: $css-color-on-primary;
&:hover {
background-color: var(--color-primary-hover, $css-color-primary);
filter: brightness(0.9);
}
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
// ==========================================================================
// CUSTOM PANEL STYLES
// ==========================================================================
&__custom-panel {
max-height: 0;
overflow: hidden;
transition: max-height 0.3s ease;
&--visible {
max-height: 800px;
}
}
// ==========================================================================
// CUSTOM COLOR INPUT STYLES
// ==========================================================================
.custom-color-input {
margin-bottom: $semantic-spacing-component-md;
&__label {
@extend .skyui-form-text--label !optional;
display: block;
margin-bottom: $semantic-spacing-component-2xs;
color: $semantic-color-text-primary;
}
&__container {
display: flex;
gap: $semantic-spacing-component-xs;
align-items: center;
}
&__picker {
width: 48px;
height: 48px;
border: none;
border-radius: $semantic-border-radius-sm;
cursor: pointer;
background: none;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
&__text {
@extend .skyui-form-text--input !optional;
flex: 1;
padding: $semantic-spacing-component-sm;
border: 1px solid $css-color-outline;
border-radius: $semantic-border-radius-sm;
background-color: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
font-family: $semantic-typography-font-family-mono;
&:focus {
border-color: $css-color-primary;
outline: none;
box-shadow: 0 0 0 2px rgba($css-color-primary, 0.2);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: $semantic-color-surface-variant;
}
}
}
// ==========================================================================
// THEME NAME INPUT STYLES
// ==========================================================================
.custom-theme-name {
margin-bottom: $semantic-spacing-component-md;
&__label {
@extend .skyui-form-text--label !optional;
display: block;
margin-bottom: $semantic-spacing-component-2xs;
color: $semantic-color-text-primary;
}
&__input {
@extend .skyui-form-text--input !optional;
width: 100%;
padding: $semantic-spacing-component-sm;
border: 1px solid $css-color-outline;
border-radius: $semantic-border-radius-sm;
background-color: $semantic-color-surface-primary;
color: $semantic-color-text-primary;
&:focus {
border-color: $css-color-primary;
outline: none;
box-shadow: 0 0 0 2px rgba($css-color-primary, 0.2);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
background-color: $semantic-color-surface-variant;
}
}
}
// ==========================================================================
// COLOR VALIDATION STYLES
// ==========================================================================
.color-validation {
margin-bottom: $semantic-spacing-component-md;
padding: $semantic-spacing-component-sm;
border-radius: $semantic-border-radius-sm;
&--error {
background-color: rgba($css-color-error, 0.1);
border: 1px solid $css-color-error;
}
&--success {
background-color: rgba($semantic-color-success, 0.1);
border: 1px solid $semantic-color-success;
}
&__title {
@extend .skyui-text--small !optional;
font-weight: $semantic-typography-font-weight-medium;
margin-bottom: $semantic-spacing-component-2xs;
}
&__list {
margin: 0;
padding-left: $semantic-spacing-component-md;
li {
@extend .skyui-text--small !optional;
margin-bottom: $semantic-spacing-component-2xs;
&:last-child {
margin-bottom: 0;
}
}
}
&--error &__title,
&--error &__list {
color: $css-color-error;
}
&--success &__title,
&--success &__list {
color: $semantic-color-success;
}
}
// ==========================================================================
// COLOR PREVIEW STYLES
// ==========================================================================
.color-preview {
margin-bottom: $semantic-spacing-component-md;
&__title {
@extend .skyui-text--small !optional;
font-weight: $semantic-typography-font-weight-medium;
color: $semantic-color-text-primary;
margin-bottom: $semantic-spacing-component-xs;
}
&__palette {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
gap: $semantic-spacing-component-2xs;
}
&__swatch {
aspect-ratio: 1;
border-radius: $semantic-border-radius-sm;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
border: 1px solid $semantic-color-border-primary;
}
&__swatch-label {
font-size: 10px;
font-weight: $semantic-typography-font-weight-medium;
text-transform: capitalize;
text-shadow: 0 0 2px rgba(0, 0, 0, 0.5);
}
}
// ==========================================================================
// ACTION BUTTON STYLES
// ==========================================================================
.custom-color-actions {
margin-bottom: $semantic-spacing-component-md;
&__apply {
@extend .skyui-interactive-text--button-medium !optional;
width: 100%;
background-color: $css-color-primary;
color: $css-color-on-primary;
border: none;
border-radius: $semantic-border-radius-md;
padding: $semantic-spacing-component-md $semantic-spacing-component-lg;
cursor: pointer;
transition: all 0.2s ease;
font-weight: $semantic-typography-font-weight-medium;
&:hover:not(:disabled) {
background-color: var(--color-primary-hover, $css-color-primary);
filter: brightness(0.9);
box-shadow: $semantic-shadow-elevated;
transform: translateY(-1px);
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
box-shadow: none;
}
}
}
// ==========================================================================
// RESET BUTTON STYLES
// ==========================================================================
&__reset-btn {
@extend .skyui-interactive-text--button-medium !optional;
width: 100%;
background-color: transparent;
color: $css-color-error;
border: 1px solid $css-color-error;
border-radius: $semantic-border-radius-md;
padding: $semantic-spacing-component-sm $semantic-spacing-component-lg;
cursor: pointer;
transition: all 0.2s ease;
&:hover:not(:disabled) {
background-color: rgba($css-color-error, 0.1);
box-shadow: $semantic-shadow-elevated;
}
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
}
// ==========================================================================
// LOADING OVERLAY STYLES
// ==========================================================================
&__loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba($css-color-scrim, 0.5);
border-radius: $semantic-border-radius-lg;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 10;
}
&__loading-spinner {
width: 32px;
height: 32px;
border: 3px solid $semantic-color-border-primary;
border-top: 3px solid $css-color-primary;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: $semantic-spacing-component-sm;
}
&__loading-text {
@extend .skyui-text--small !optional;
color: $semantic-color-text-primary;
font-weight: $semantic-typography-font-weight-medium;
}
}
// ==========================================================================
// ANIMATIONS
// ==========================================================================
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
// ==========================================================================
// RESPONSIVE DESIGN
// ==========================================================================
@media (max-width: $semantic-breakpoint-sm) {
.theme-switcher {
padding: $semantic-spacing-component-md;
.theme-grid {
grid-template-columns: repeat(auto-fit, minmax(80px, 1fr));
}
.color-preview {
&__palette {
grid-template-columns: repeat(auto-fit, minmax(60px, 1fr));
}
}
}
}

View File

@@ -0,0 +1,471 @@
/**
* ==========================================================================
* THEME SWITCHER COMPONENT
* ==========================================================================
* Interactive component for runtime theme switching.
* Supports predefined themes, custom colors, and dark mode toggle.
* Provides color validation and preview functionality.
* ==========================================================================
*/
import { Component, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Subject } from 'rxjs';
import { takeUntil, debounceTime, distinctUntilChanged } from 'rxjs/operators';
import { ThemeService, ThemeConfig, ThemeState } from '../../../core/services/theme.service';
@Component({
selector: 'app-theme-switcher',
standalone: true,
imports: [CommonModule, FormsModule],
template: `
<!--
==========================================================================
THEME SWITCHER TEMPLATE
==========================================================================
Interactive UI for runtime theme switching with Material Design 3 styling
==========================================================================
-->
<div class="theme-switcher">
<!-- Theme Switcher Header -->
<div class="theme-switcher__header">
<h3 class="theme-switcher__title">Theme Settings</h3>
<p class="theme-switcher__subtitle">Customize your app's appearance</p>
</div>
<!-- Dark Mode Toggle -->
<div class="theme-switcher__section">
<div class="theme-switcher__option">
<label class="theme-switcher__label">
<input
type="checkbox"
class="theme-switcher__checkbox"
[checked]="isDarkMode"
(change)="onDarkModeToggle()"
[disabled]="isApplyingTheme"
>
<span class="theme-switcher__checkbox-custom"></span>
<span class="theme-switcher__text">Dark mode</span>
</label>
</div>
</div>
<!-- Predefined Themes -->
<div class="theme-switcher__section">
<h4 class="theme-switcher__section-title">Predefined Themes</h4>
<div class="theme-grid">
@for (theme of availableThemes; track theme.name) {
<div
class="theme-card"
[class.theme-card--active]="isThemeActive(theme.name)"
[class.theme-card--disabled]="isApplyingTheme"
(click)="onThemeChange(theme.name)"
>
<div class="theme-card__preview">
<div
class="theme-card__color-primary"
[style.backgroundColor]="theme.primary"
></div>
<div class="theme-card__color-details">
<div
class="theme-card__color-secondary"
[style.backgroundColor]="theme.secondary || '#625B71'"
></div>
<div
class="theme-card__color-tertiary"
[style.backgroundColor]="theme.tertiary || '#7D5260'"
></div>
</div>
</div>
<span class="theme-card__name">{{ getThemeDisplayName(theme.name) }}</span>
@if (isThemeActive(theme.name)) {
<div class="theme-card__active-indicator">
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/>
</svg>
</div>
}
</div>
}
</div>
</div>
<!-- Custom Color Section -->
<div class="theme-switcher__section">
<div class="theme-switcher__section-header">
<h4 class="theme-switcher__section-title">Custom Color</h4>
<button
type="button"
class="theme-switcher__toggle-btn"
[class.theme-switcher__toggle-btn--active]="showCustomColorPanel"
(click)="onToggleCustomPanel()"
[disabled]="isApplyingTheme"
>
{{ showCustomColorPanel ? 'Hide' : 'Customize' }}
</button>
</div>
<div
class="theme-switcher__custom-panel"
[class.theme-switcher__custom-panel--visible]="showCustomColorPanel"
>
<!-- Color Input -->
<div class="custom-color-input">
<label class="custom-color-input__label">Primary Color</label>
<div class="custom-color-input__container">
<input
type="color"
class="custom-color-input__picker"
[(ngModel)]="customColorInput"
(input)="onCustomColorChange($any($event.target).value || '')"
[disabled]="isApplyingTheme"
>
<input
type="text"
class="custom-color-input__text"
[(ngModel)]="customColorInput"
(input)="onCustomColorChange($any($event.target).value || '')"
placeholder="#6750A4"
[disabled]="isApplyingTheme"
>
</div>
</div>
<!-- Theme Name Input -->
<div class="custom-theme-name">
<label class="custom-theme-name__label">Theme Name (Optional)</label>
<input
type="text"
class="custom-theme-name__input"
[(ngModel)]="customThemeName"
placeholder="My Custom Theme"
[disabled]="isApplyingTheme"
>
</div>
<!-- Color Validation -->
@if (colorValidation) {
<div
class="color-validation"
[class.color-validation--error]="!colorValidation.isValid"
[class.color-validation--success]="colorValidation.isValid"
>
@if (!colorValidation.isValid) {
<div class="color-validation__warnings">
<div class="color-validation__title">⚠️ Issues found:</div>
<ul class="color-validation__list">
@for (warning of colorValidation.warnings; track warning) {
<li>{{ warning }}</li>
}
</ul>
</div>
}
@if (colorValidation.recommendations) {
<div class="color-validation__recommendations">
<div class="color-validation__title">💡 Recommendations:</div>
<ul class="color-validation__list">
@for (rec of colorValidation.recommendations; track rec) {
<li>{{ rec }}</li>
}
</ul>
</div>
}
</div>
}
<!-- Color Preview -->
@if (showColorPreview && colorValidation?.isValid) {
<div class="color-preview">
<div class="color-preview__title">Color Preview</div>
<div class="color-preview__palette">
@for (color of colorPreview | keyvalue; track color.key) {
<div
class="color-preview__swatch"
[style.backgroundColor]="color.value"
[style.color]="getContrastTextColor(color.value)"
>
<span class="color-preview__swatch-label">{{ color.key }}</span>
</div>
}
</div>
</div>
}
<!-- Apply Button -->
<div class="custom-color-actions">
<button
type="button"
class="custom-color-actions__apply"
[disabled]="!colorValidation?.isValid || isApplyingTheme"
(click)="onApplyCustomColor()"
>
@if (!isApplyingTheme) {
<span>Apply Custom Theme</span>
} @else {
<span>Applying...</span>
}
</button>
</div>
</div>
</div>
<!-- Reset Section -->
<div class="theme-switcher__section">
<button
type="button"
class="theme-switcher__reset-btn"
(click)="onResetTheme()"
[disabled]="isApplyingTheme"
>
Reset to Default
</button>
</div>
<!-- Loading Overlay -->
@if (isApplyingTheme) {
<div class="theme-switcher__loading-overlay">
<div class="theme-switcher__loading-spinner"></div>
<span class="theme-switcher__loading-text">Applying theme...</span>
</div>
}
</div>
`,
styleUrls: ['./theme-switcher.component.scss']
})
export class ThemeSwitcherComponent implements OnInit, OnDestroy {
private destroy$ = new Subject<void>();
// Component state
currentTheme: ThemeState = {
currentTheme: 'default',
isDark: false,
primaryColor: '#6750A4',
customThemes: []
};
availableThemes: ThemeConfig[] = [];
isDarkMode = false;
// Custom color input
customColorInput = '#6750A4';
customThemeName = 'My Theme';
colorPreview: { [key: string]: string } = {};
colorValidation: { isValid: boolean; warnings: string[]; recommendations?: string[]; } | null = null;
// UI state
showCustomColorPanel = false;
showColorPreview = false;
isApplyingTheme = false;
constructor(private themeService: ThemeService) {}
ngOnInit(): void {
this.initializeComponent();
this.setupSubscriptions();
}
ngOnDestroy(): void {
this.destroy$.next();
this.destroy$.complete();
}
// ==========================================================================
// INITIALIZATION METHODS
// ==========================================================================
/**
* Initializes component state
*/
private initializeComponent(): void {
this.availableThemes = this.themeService.getAvailableThemes();
this.currentTheme = this.themeService.getCurrentTheme();
this.isDarkMode = this.themeService.isDarkMode();
this.customColorInput = this.currentTheme.primaryColor;
}
/**
* Sets up reactive subscriptions
*/
private setupSubscriptions(): void {
// Listen to theme state changes
this.themeService.themeState$
.pipe(takeUntil(this.destroy$))
.subscribe(themeState => {
this.currentTheme = themeState;
this.availableThemes = this.themeService.getAvailableThemes();
});
// Listen to dark mode changes
this.themeService.isDark$
.pipe(takeUntil(this.destroy$))
.subscribe(isDark => {
this.isDarkMode = isDark;
});
}
// ==========================================================================
// THEME SWITCHING METHODS
// ==========================================================================
/**
* Applies a predefined theme
* @param themeName - Name of the theme to apply
*/
onThemeChange(themeName: string): void {
if (themeName !== this.currentTheme.currentTheme) {
this.isApplyingTheme = true;
setTimeout(() => {
this.themeService.applyTheme(themeName, this.isDarkMode);
this.isApplyingTheme = false;
}, 100);
}
}
/**
* Toggles dark mode
*/
onDarkModeToggle(): void {
this.themeService.toggleDarkMode();
}
/**
* Resets to default theme
*/
onResetTheme(): void {
this.isApplyingTheme = true;
setTimeout(() => {
this.themeService.resetToDefault();
this.customColorInput = this.themeService.getCurrentTheme().primaryColor;
this.isApplyingTheme = false;
}, 100);
}
// ==========================================================================
// CUSTOM COLOR METHODS
// ==========================================================================
/**
* Handles custom color input changes
* @param color - Color input value
*/
onCustomColorChange(color: string): void {
this.customColorInput = color;
this.validateAndPreviewColor(color);
}
/**
* Validates and previews a custom color
* @param color - Color to validate and preview
*/
private validateAndPreviewColor(color: string): void {
// Clear previous validation
this.colorValidation = null;
this.colorPreview = {};
this.showColorPreview = false;
if (!color || color.length < 4) {
return;
}
try {
// Validate color
this.colorValidation = this.themeService.validateColor(color);
// Generate preview if valid
if (this.colorValidation?.isValid) {
this.colorPreview = this.themeService.generateColorPreview(color);
this.showColorPreview = true;
}
} catch (error) {
this.colorValidation = {
isValid: false,
warnings: ['Invalid color format']
};
}
}
/**
* Applies custom color theme
*/
onApplyCustomColor(): void {
if (!this.colorValidation?.isValid) {
return;
}
this.isApplyingTheme = true;
setTimeout(() => {
this.themeService.applyCustomTheme(
this.customColorInput,
this.customThemeName || 'Custom Theme',
this.isDarkMode
);
this.showCustomColorPanel = false;
this.isApplyingTheme = false;
}, 100);
}
/**
* Toggles custom color panel
*/
onToggleCustomPanel(): void {
this.showCustomColorPanel = !this.showCustomColorPanel;
if (this.showCustomColorPanel) {
this.validateAndPreviewColor(this.customColorInput);
}
}
// ==========================================================================
// UTILITY METHODS
// ==========================================================================
/**
* Gets display name for theme
* @param themeName - Theme internal name
* @returns Human-readable theme name
*/
getThemeDisplayName(themeName: string): string {
const displayNames: { [key: string]: string } = {
'default': 'Material Purple',
'ocean': 'Ocean Blue',
'forest': 'Forest Green',
'sunset': 'Sunset Orange'
};
return displayNames[themeName] || themeName;
}
/**
* Gets CSS class for theme preview
* @param themeName - Theme name
* @returns CSS class name
*/
getThemePreviewClass(themeName: string): string {
return `theme-preview--${themeName}`;
}
/**
* Checks if theme is currently active
* @param themeName - Theme name to check
* @returns True if theme is active
*/
isThemeActive(themeName: string): boolean {
return this.currentTheme.currentTheme === themeName;
}
/**
* Gets color contrast text color
* @param backgroundColor - Background color
* @returns Text color (black or white)
*/
getContrastTextColor(backgroundColor: string): string {
// Simple contrast check - in production you might want to use ColorUtils.getContrast
const hex = backgroundColor.replace('#', '');
const r = parseInt(hex.substr(0, 2), 16);
const g = parseInt(hex.substr(2, 2), 16);
const b = parseInt(hex.substr(4, 2), 16);
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
return luminance > 0.5 ? '#000000' : '#FFFFFF';
}
}

View File

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

Some files were not shown because too many files have changed in this diff Show More