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:
7
ng-package.json
Normal file
7
ng-package.json
Normal 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
12
package.json
Normal 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
|
||||||
|
}
|
||||||
247
src/lib/components/buttons/button.component.scss
Normal file
247
src/lib/components/buttons/button.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/lib/components/buttons/button.component.ts
Normal file
75
src/lib/components/buttons/button.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
475
src/lib/components/buttons/fab-menu/fab-menu.component.scss
Normal file
475
src/lib/components/buttons/fab-menu/fab-menu.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
285
src/lib/components/buttons/fab-menu/fab-menu.component.ts
Normal file
285
src/lib/components/buttons/fab-menu/fab-menu.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/buttons/fab-menu/index.ts
Normal file
1
src/lib/components/buttons/fab-menu/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './fab-menu.component';
|
||||||
303
src/lib/components/buttons/fab.component.scss
Normal file
303
src/lib/components/buttons/fab.component.scss
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
79
src/lib/components/buttons/fab.component.ts
Normal file
79
src/lib/components/buttons/fab.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
141
src/lib/components/buttons/ghost-button.component.scss
Normal file
141
src/lib/components/buttons/ghost-button.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
55
src/lib/components/buttons/ghost-button.component.ts
Normal file
55
src/lib/components/buttons/ghost-button.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/buttons/icon-button/index.ts
Normal file
1
src/lib/components/buttons/icon-button/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './icon-button.component';
|
||||||
5
src/lib/components/buttons/index copy.ts
Normal file
5
src/lib/components/buttons/index copy.ts
Normal 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';
|
||||||
8
src/lib/components/buttons/index.ts
Normal file
8
src/lib/components/buttons/index.ts
Normal 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';
|
||||||
91
src/lib/components/buttons/simple-button.component.scss
Normal file
91
src/lib/components/buttons/simple-button.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
41
src/lib/components/buttons/simple-button.component.ts
Normal file
41
src/lib/components/buttons/simple-button.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/buttons/split-button/index.ts
Normal file
1
src/lib/components/buttons/split-button/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './split-button.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
src/lib/components/buttons/text-button.component.scss
Normal file
132
src/lib/components/buttons/text-button.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
53
src/lib/components/buttons/text-button.component.ts
Normal file
53
src/lib/components/buttons/text-button.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
370
src/lib/components/data-display/accordion/accordion.component.ts
Normal file
370
src/lib/components/data-display/accordion/accordion.component.ts
Normal 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 = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
2
src/lib/components/data-display/accordion/index.ts
Normal file
2
src/lib/components/data-display/accordion/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export { AccordionComponent, AccordionItemComponent } from './accordion.component';
|
||||||
|
export type { AccordionSize, AccordionVariant, AccordionItem, AccordionExpandEvent } from './accordion.component';
|
||||||
244
src/lib/components/data-display/avatar/avatar.component.scss
Normal file
244
src/lib/components/data-display/avatar/avatar.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
108
src/lib/components/data-display/avatar/avatar.component.ts
Normal file
108
src/lib/components/data-display/avatar/avatar.component.ts
Normal 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 '';
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/data-display/avatar/index.ts
Normal file
1
src/lib/components/data-display/avatar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './avatar.component';
|
||||||
176
src/lib/components/data-display/badge/badge.component.scss
Normal file
176
src/lib/components/data-display/badge/badge.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/lib/components/data-display/badge/badge.component.ts
Normal file
37
src/lib/components/data-display/badge/badge.component.ts
Normal 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;
|
||||||
|
}
|
||||||
1
src/lib/components/data-display/badge/index.ts
Normal file
1
src/lib/components/data-display/badge/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './badge.component';
|
||||||
400
src/lib/components/data-display/card/card.component.scss
Normal file
400
src/lib/components/data-display/card/card.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
99
src/lib/components/data-display/card/card.component.ts
Normal file
99
src/lib/components/data-display/card/card.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/data-display/card/index.ts
Normal file
1
src/lib/components/data-display/card/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './card.component';
|
||||||
375
src/lib/components/data-display/carousel/carousel.component.scss
Normal file
375
src/lib/components/data-display/carousel/carousel.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
277
src/lib/components/data-display/carousel/carousel.component.ts
Normal file
277
src/lib/components/data-display/carousel/carousel.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/data-display/carousel/index.ts
Normal file
1
src/lib/components/data-display/carousel/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './carousel.component';
|
||||||
451
src/lib/components/data-display/chip/chip.component.scss
Normal file
451
src/lib/components/data-display/chip/chip.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
105
src/lib/components/data-display/chip/chip.component.ts
Normal file
105
src/lib/components/data-display/chip/chip.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/data-display/chip/index.ts
Normal file
1
src/lib/components/data-display/chip/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './chip.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/data-display/image-container/index.ts
Normal file
1
src/lib/components/data-display/image-container/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './image-container.component';
|
||||||
17
src/lib/components/data-display/index.ts
Normal file
17
src/lib/components/data-display/index.ts
Normal 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';
|
||||||
223
src/lib/components/data-display/list/README.md
Normal file
223
src/lib/components/data-display/list/README.md
Normal 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>
|
||||||
|
```
|
||||||
23
src/lib/components/data-display/list/index.ts
Normal file
23
src/lib/components/data-display/list/index.ts
Normal 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';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
374
src/lib/components/data-display/list/list-examples.component.ts
Normal file
374
src/lib/components/data-display/list/list-examples.component.ts
Normal 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
|
||||||
|
}
|
||||||
|
];
|
||||||
|
}
|
||||||
360
src/lib/components/data-display/list/list-item.component.scss
Normal file
360
src/lib/components/data-display/list/list-item.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
109
src/lib/components/data-display/list/list-item.component.ts
Normal file
109
src/lib/components/data-display/list/list-item.component.ts
Normal 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(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/data-display/progress/index.ts
Normal file
1
src/lib/components/data-display/progress/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './progress-bar.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
3
src/lib/components/data-display/table/index.ts
Normal file
3
src/lib/components/data-display/table/index.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
export * from './table.component';
|
||||||
|
export * from './enhanced-table.component';
|
||||||
|
export * from './table-actions.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
177
src/lib/components/data-display/table/table-actions.component.ts
Normal file
177
src/lib/components/data-display/table/table-actions.component.ts
Normal 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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
441
src/lib/components/data-display/table/table.component.scss
Normal file
441
src/lib/components/data-display/table/table.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
246
src/lib/components/data-display/table/table.component.ts
Normal file
246
src/lib/components/data-display/table/table.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/data-display/timeline/index.ts
Normal file
1
src/lib/components/data-display/timeline/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './timeline.component';
|
||||||
331
src/lib/components/data-display/timeline/timeline.component.scss
Normal file
331
src/lib/components/data-display/timeline/timeline.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
115
src/lib/components/data-display/timeline/timeline.component.ts
Normal file
115
src/lib/components/data-display/timeline/timeline.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/data-display/tooltip/index.ts
Normal file
1
src/lib/components/data-display/tooltip/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './tooltip.component';
|
||||||
189
src/lib/components/data-display/tooltip/tooltip.component.scss
Normal file
189
src/lib/components/data-display/tooltip/tooltip.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
152
src/lib/components/data-display/tooltip/tooltip.component.ts
Normal file
152
src/lib/components/data-display/tooltip/tooltip.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/data-display/transfer-list/index.ts
Normal file
1
src/lib/components/data-display/transfer-list/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './transfer-list.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/data-display/tree-view/index.ts
Normal file
1
src/lib/components/data-display/tree-view/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './tree-view.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
297
src/lib/components/data-display/tree-view/tree-view.component.ts
Normal file
297
src/lib/components/data-display/tree-view/tree-view.component.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
221
src/lib/components/feedback/alert/alert.component.scss
Normal file
221
src/lib/components/feedback/alert/alert.component.scss
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
136
src/lib/components/feedback/alert/alert.component.ts
Normal file
136
src/lib/components/feedback/alert/alert.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/feedback/alert/index.ts
Normal file
1
src/lib/components/feedback/alert/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './alert.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/feedback/empty-state/index.ts
Normal file
1
src/lib/components/feedback/empty-state/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './empty-state.component';
|
||||||
9
src/lib/components/feedback/index.ts
Normal file
9
src/lib/components/feedback/index.ts
Normal 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
|
||||||
1
src/lib/components/feedback/loading-spinner/index.ts
Normal file
1
src/lib/components/feedback/loading-spinner/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './loading-spinner.component';
|
||||||
@@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/feedback/progress-circle/index.ts
Normal file
1
src/lib/components/feedback/progress-circle/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './progress-circle.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/feedback/skeleton-loader/index.ts
Normal file
1
src/lib/components/feedback/skeleton-loader/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './skeleton-loader.component';
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
1
src/lib/components/feedback/snackbar/index.ts
Normal file
1
src/lib/components/feedback/snackbar/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './snackbar.component';
|
||||||
235
src/lib/components/feedback/snackbar/snackbar.component.scss
Normal file
235
src/lib/components/feedback/snackbar/snackbar.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
243
src/lib/components/feedback/snackbar/snackbar.component.ts
Normal file
243
src/lib/components/feedback/snackbar/snackbar.component.ts
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
253
src/lib/components/feedback/status-badge.component.scss
Normal file
253
src/lib/components/feedback/status-badge.component.scss
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
54
src/lib/components/feedback/status-badge.component.ts
Normal file
54
src/lib/components/feedback/status-badge.component.ts
Normal 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(' ');
|
||||||
|
}
|
||||||
|
}
|
||||||
579
src/lib/components/feedback/theme-switcher.component.scss
Normal file
579
src/lib/components/feedback/theme-switcher.component.scss
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
471
src/lib/components/feedback/theme-switcher.component.ts
Normal file
471
src/lib/components/feedback/theme-switcher.component.ts
Normal 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/lib/components/feedback/toast/index.ts
Normal file
1
src/lib/components/feedback/toast/index.ts
Normal file
@@ -0,0 +1 @@
|
|||||||
|
export * from './toast.component';
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user