From 6b8352a8a07433ef3bdb5bc31dea77012b76abd9 Mon Sep 17 00:00:00 2001 From: skyai_dev Date: Wed, 3 Sep 2025 12:01:35 +1000 Subject: [PATCH] Add comprehensive toast notification component to feedback category MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Create new toast component with full TypeScript implementation - Add SCSS styling using semantic design tokens exclusively - Implement size variants (sm, md, lg) and color variants (primary, success, warning, danger, info) - Include interactive features: auto-dismiss, manual dismiss, progress bar, hover pause/resume - Add comprehensive accessibility support with ARIA attributes and keyboard navigation - Create engaging demo component with interactive examples and live statistics - Integrate toast into feedback category menu and routing system - Follow established component patterns and design system conventions 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- .../src/app/demos/demos.routes.ts | 33 +- .../toast-demo/toast-demo.component.scss | 233 ++++++++++++++ .../demos/toast-demo/toast-demo.component.ts | 247 +++++++++++++++ .../features/dashboard/dashboard.component.ts | 16 +- .../src/lib/components/feedback/index.ts | 2 + .../lib/components/feedback/toast/index.ts | 1 + .../feedback/toast/toast.component.scss | 289 ++++++++++++++++++ .../feedback/toast/toast.component.ts | 238 +++++++++++++++ 8 files changed, 1056 insertions(+), 3 deletions(-) create mode 100644 projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.scss create mode 100644 projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.ts create mode 100644 projects/ui-essentials/src/lib/components/feedback/toast/index.ts create mode 100644 projects/ui-essentials/src/lib/components/feedback/toast/toast.component.scss create mode 100644 projects/ui-essentials/src/lib/components/feedback/toast/toast.component.ts diff --git a/projects/demo-ui-essentials/src/app/demos/demos.routes.ts b/projects/demo-ui-essentials/src/app/demos/demos.routes.ts index b772630..a03f87d 100644 --- a/projects/demo-ui-essentials/src/app/demos/demos.routes.ts +++ b/projects/demo-ui-essentials/src/app/demos/demos.routes.ts @@ -43,6 +43,12 @@ import { ProgressCircleDemoComponent } from './progress-circle-demo/progress-cir import { RangeSliderDemoComponent } from './range-slider-demo/range-slider-demo.component'; import { DividerDemoComponent } from './divider-demo/divider-demo.component'; import { TooltipDemoComponent } from './tooltip-demo/tooltip-demo.component'; +import { AccordionDemoComponent } from './accordion-demo/accordion-demo.component'; +import { PopoverDemoComponent } from './popover-demo/popover-demo.component'; +import { AlertDemoComponent } from './alert-demo/alert-demo.component'; +import { ToastDemoComponent } from './toast-demo/toast-demo.component'; +import { TreeViewDemoComponent } from './tree-view-demo/tree-view-demo.component'; +import { TimelineDemoComponent } from './timeline-demo/timeline-demo.component'; @Component({ @@ -213,6 +219,30 @@ import { TooltipDemoComponent } from './tooltip-demo/tooltip-demo.component'; } + @case ("accordion") { + + } + + @case ("popover") { + + } + + @case ("alert") { + + } + + @case ("toast") { + + } + + @case ("tree-view") { + + } + + @case ("timeline") { + + } + } `, @@ -227,7 +257,8 @@ import { TooltipDemoComponent } from './tooltip-demo/tooltip-demo.component'; GridSystemDemoComponent, SpacerDemoComponent, ContainerDemoComponent, PaginationDemoComponent, SkeletonLoaderDemoComponent, EmptyStateDemoComponent, FileUploadDemoComponent, FormFieldDemoComponent, AutocompleteDemoComponent, BackdropDemoComponent, OverlayContainerDemoComponent, LoadingSpinnerDemoComponent, - ProgressCircleDemoComponent, RangeSliderDemoComponent, DividerDemoComponent, TooltipDemoComponent] + ProgressCircleDemoComponent, RangeSliderDemoComponent, DividerDemoComponent, TooltipDemoComponent, AccordionDemoComponent, + PopoverDemoComponent, AlertDemoComponent, ToastDemoComponent, TreeViewDemoComponent, TimelineDemoComponent] }) diff --git a/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.scss b/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.scss new file mode 100644 index 0000000..194602e --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.scss @@ -0,0 +1,233 @@ +@use '../../../../../../../../shared-ui/src/styles/semantic' as *; + +.demo-container { + padding: $semantic-spacing-layout-section-md; + max-width: 1200px; + margin: 0 auto; +} + +.demo-section { + margin-bottom: $semantic-spacing-layout-section-lg; + + h3 { + font-family: map-get($semantic-typography-heading-h3, font-family); + font-size: map-get($semantic-typography-heading-h3, font-size); + font-weight: map-get($semantic-typography-heading-h3, font-weight); + line-height: map-get($semantic-typography-heading-h3, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-content-heading; + } + + h4 { + font-family: map-get($semantic-typography-heading-h4, font-family); + font-size: map-get($semantic-typography-heading-h4, font-size); + font-weight: map-get($semantic-typography-heading-h4, font-weight); + line-height: map-get($semantic-typography-heading-h4, line-height); + color: $semantic-color-text-primary; + margin-bottom: $semantic-spacing-content-heading; + } + + p { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-secondary; + margin-bottom: $semantic-spacing-content-paragraph; + } +} + +.demo-row { + display: flex; + flex-wrap: wrap; + gap: $semantic-spacing-grid-gap-md; + align-items: flex-start; +} + +.toast-wrapper { + flex: 0 0 auto; +} + +.controls-section { + margin-bottom: $semantic-spacing-layout-section-md; +} + +.button-group { + display: flex; + flex-wrap: wrap; + gap: $semantic-spacing-component-sm; + margin-bottom: $semantic-spacing-component-lg; +} + +.demo-button { + display: inline-flex; + align-items: center; + gap: $semantic-spacing-component-xs; + padding: $semantic-spacing-interactive-button-padding-y $semantic-spacing-interactive-button-padding-x; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-button-radius; + color: $semantic-color-text-primary; + font-family: map-get($semantic-typography-button-medium, font-family); + font-size: map-get($semantic-typography-button-medium, font-size); + font-weight: map-get($semantic-typography-button-medium, font-weight); + line-height: map-get($semantic-typography-button-medium, line-height); + cursor: pointer; + transition: all $semantic-motion-duration-fast $semantic-motion-easing-ease; + min-height: $semantic-sizing-button-height-md; + + &:hover { + box-shadow: $semantic-shadow-button-hover; + background: $semantic-color-surface-secondary; + } + + &:focus-visible { + outline: 2px solid $semantic-color-focus; + outline-offset: 2px; + } + + &:active { + box-shadow: $semantic-shadow-button-rest; + transform: translateY(1px); + } + + &--primary { + background: $semantic-color-primary; + color: $semantic-color-on-primary; + border-color: $semantic-color-primary; + + &:hover { + background: $semantic-color-primary; + opacity: $semantic-opacity-hover; + } + } + + &--secondary { + background: $semantic-color-secondary; + color: $semantic-color-on-secondary; + border-color: $semantic-color-secondary; + + &:hover { + background: $semantic-color-secondary; + opacity: $semantic-opacity-hover; + } + } + + &--warning { + background: $semantic-color-warning; + color: $semantic-color-on-warning; + border-color: $semantic-color-warning; + + &:hover { + background: $semantic-color-warning; + opacity: $semantic-opacity-hover; + } + } + + &--danger { + background: $semantic-color-danger; + color: $semantic-color-on-danger; + border-color: $semantic-color-danger; + + &:hover { + background: $semantic-color-danger; + opacity: $semantic-opacity-hover; + } + } +} + +.toast-container { + position: fixed; + top: $semantic-spacing-layout-section-md; + right: $semantic-spacing-layout-section-md; + z-index: $semantic-z-index-modal; + display: flex; + flex-direction: column; + gap: $semantic-spacing-component-sm; + max-width: 400px; + width: 100%; + pointer-events: none; + + .ui-toast { + pointer-events: auto; + } +} + +.stats-section { + margin-top: $semantic-spacing-layout-section-md; + padding: $semantic-spacing-component-lg; + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-card-radius; +} + +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: $semantic-spacing-grid-gap-md; + margin-bottom: $semantic-spacing-component-lg; +} + +.stat-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: $semantic-spacing-component-sm; + background: $semantic-color-surface-primary; + border: $semantic-border-width-1 solid $semantic-color-border-subtle; + border-radius: $semantic-border-radius-sm; +} + +.stat-label { + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-secondary; +} + +.stat-value { + font-family: map-get($semantic-typography-label, font-family); + font-size: map-get($semantic-typography-label, font-size); + font-weight: $semantic-typography-font-weight-semibold; + line-height: map-get($semantic-typography-label, line-height); + color: $semantic-color-primary; +} + +// Responsive Design +@media (max-width: $semantic-breakpoint-md - 1) { + .demo-container { + padding: $semantic-spacing-layout-section-sm; + } + + .demo-row { + flex-direction: column; + } + + .toast-container { + top: $semantic-spacing-layout-section-sm; + right: $semantic-spacing-layout-section-sm; + left: $semantic-spacing-layout-section-sm; + max-width: none; + } + + .button-group { + flex-direction: column; + } + + .stats-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: $semantic-breakpoint-sm - 1) { + .demo-container { + padding: $semantic-spacing-layout-section-xs; + } + + .toast-container { + top: $semantic-spacing-layout-section-xs; + right: $semantic-spacing-layout-section-xs; + left: $semantic-spacing-layout-section-xs; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.ts b/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.ts new file mode 100644 index 0000000..bfa30ab --- /dev/null +++ b/projects/demo-ui-essentials/src/app/demos/toast-demo/toast-demo.component.ts @@ -0,0 +1,247 @@ +import { Component } from '@angular/core'; +import { CommonModule } from '@angular/common'; +import { ToastComponent } from '../../../../../../../ui-essentials/src/lib/components/feedback/toast/toast.component'; +import { FontAwesomeModule } from '@fortawesome/angular-fontawesome'; +import { faPlay, faStop, faRedo } from '@fortawesome/free-solid-svg-icons'; + +@Component({ + selector: 'ui-toast-demo', + standalone: true, + imports: [CommonModule, ToastComponent, FontAwesomeModule], + template: ` +
+

Toast Demo

+

Toast notifications provide contextual feedback messages for user actions.

+ + +
+

Sizes

+
+ @for (size of sizes; track size) { +
+ + This is a {{ size }} toast message. + +
+ } +
+
+ + +
+

Variants

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

Configuration Options

+
+
+ + This toast cannot be manually dismissed. + +
+ +
+ + This toast has no icon displayed. + +
+ +
+ + This toast shows a progress indicator. + +
+
+
+ + +
+

Interactive Examples

+ +
+

Toast Controls

+
+ + + + + + + +
+
+ + +
+ @for (toast of activeToasts; track toast.id) { + + {{ toast.message }} + + } +
+ + +
+

Demo Statistics

+
+
+ Toasts Shown: + {{ toastCount }} +
+
+ Currently Active: + {{ activeToasts.length }} +
+
+ Auto Dismissed: + {{ expiredCount }} +
+
+ Manually Dismissed: + {{ dismissedCount }} +
+
+ + +
+
+
+ `, + styleUrl: './toast-demo.component.scss' +}) +export class ToastDemoComponent { + sizes = ['sm', 'md', 'lg'] as const; + variants = ['primary', 'success', 'warning', 'danger', 'info'] as const; + + // Icons + readonly faPlay = faPlay; + readonly faStop = faStop; + readonly faRedo = faRedo; + + // Toast management + activeToasts: any[] = []; + private nextToastId = 1; + + // Statistics + toastCount = 0; + expiredCount = 0; + dismissedCount = 0; + + getVariantTitle(variant: string): string { + const titles = { + primary: 'Primary Toast', + success: 'Success Toast', + warning: 'Warning Toast', + danger: 'Error Toast', + info: 'Information Toast' + }; + return titles[variant as keyof typeof titles] || 'Toast'; + } + + getVariantMessage(variant: string): string { + const messages = { + primary: 'This is a primary notification message.', + success: 'Operation completed successfully!', + warning: 'Please review your input carefully.', + danger: 'An error occurred during processing.', + info: 'Here is some helpful information.' + }; + return messages[variant as keyof typeof messages] || 'Toast message'; + } + + showToast(variant: any, title: string, message: string): void { + const toast = { + id: this.nextToastId++, + variant, + title, + message, + size: 'md' as const, + duration: 4000, + autoDismiss: true, + showProgress: true + }; + + this.activeToasts.push(toast); + this.toastCount++; + } + + removeToast(id: number): void { + const index = this.activeToasts.findIndex(toast => toast.id === id); + if (index > -1) { + this.activeToasts.splice(index, 1); + } + } + + clearToasts(): void { + this.dismissedCount += this.activeToasts.length; + this.activeToasts = []; + } + + resetStats(): void { + this.toastCount = 0; + this.expiredCount = 0; + this.dismissedCount = 0; + this.activeToasts = []; + this.nextToastId = 1; + } +} \ No newline at end of file diff --git a/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts b/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts index 0f1e9de..635ad54 100644 --- a/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts +++ b/projects/demo-ui-essentials/src/app/features/dashboard/dashboard.component.ts @@ -9,7 +9,8 @@ import { faVideo, faComment, faMousePointer, faLayerGroup, faSquare, faCalendarDays, faClock, faGripVertical, faArrowsAlt, faBoxOpen, faChevronLeft, faSpinner, faExclamationTriangle, faCloudUploadAlt, faFileText, faListAlt, faCircle, faExpandArrowsAlt, faCircleNotch, faSliders, - faMinus, faInfoCircle + faMinus, faInfoCircle, faChevronDown, faCaretUp, faExclamationCircle, faSitemap, faStream, + faBell } from '@fortawesome/free-solid-svg-icons'; import { DemoRoutes } from '../../demos'; import { ScrollContainerComponent } from '../../../../../ui-essentials/src/lib/layouts/scroll-container.component'; @@ -94,6 +95,11 @@ export class DashboardComponent { faSliders = faSliders; faMinus = faMinus; faInfoCircle = faInfoCircle; + faChevronDown = faChevronDown; + faCaretUp = faCaretUp; + faSitemap = faSitemap; + faStream = faStream; + faBell = faBell; menuItems: any = [] @@ -145,6 +151,7 @@ export class DashboardComponent { // Data Display category const dataDisplayChildren = [ + this.createChildItem("accordion", "Accordion", this.faChevronDown), this.createChildItem("table", "Tables", this.faTable), this.createChildItem("lists", "Lists", this.faList), this.createChildItem("progress", "Progress Bars", this.faCheckSquare), @@ -152,7 +159,9 @@ export class DashboardComponent { this.createChildItem("avatar", "Avatars", this.faUserCircle), this.createChildItem("cards", "Cards", this.faIdCard), this.createChildItem("divider", "Divider", this.faMinus), - this.createChildItem("tooltip", "Tooltip", this.faInfoCircle) + this.createChildItem("timeline", "Timeline", this.faStream), + this.createChildItem("tooltip", "Tooltip", this.faInfoCircle), + this.createChildItem("tree-view", "Tree View", this.faSitemap) ]; this.addMenuItem("data-display", "Data Display", this.faEye, dataDisplayChildren); @@ -180,6 +189,8 @@ export class DashboardComponent { // Feedback category const feedbackChildren = [ + this.createChildItem("alert", "Alert", faExclamationCircle), + this.createChildItem("toast", "Toast", this.faBell), this.createChildItem("chips", "Chips", this.faTags), this.createChildItem("loading-spinner", "Loading Spinner", this.faSpinner), this.createChildItem("skeleton-loader", "Skeleton Loader", this.faSpinner), @@ -201,6 +212,7 @@ export class DashboardComponent { const overlaysChildren = [ this.createChildItem("modal", "Modal/Dialog", this.faSquare), this.createChildItem("drawer", "Drawer/Sidebar", this.faBars), + this.createChildItem("popover", "Popover/Dropdown", this.faCaretUp), this.createChildItem("backdrop", "Backdrop", this.faCircle), this.createChildItem("overlay-container", "Overlay Container", this.faExpandArrowsAlt) ]; diff --git a/projects/ui-essentials/src/lib/components/feedback/index.ts b/projects/ui-essentials/src/lib/components/feedback/index.ts index bb66b99..66d6892 100644 --- a/projects/ui-essentials/src/lib/components/feedback/index.ts +++ b/projects/ui-essentials/src/lib/components/feedback/index.ts @@ -1,6 +1,8 @@ +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 "./toast"; // export * from "./theme-switcher.component"; // Temporarily disabled due to CSS variable issues diff --git a/projects/ui-essentials/src/lib/components/feedback/toast/index.ts b/projects/ui-essentials/src/lib/components/feedback/toast/index.ts new file mode 100644 index 0000000..dab40b7 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/feedback/toast/index.ts @@ -0,0 +1 @@ +export * from './toast.component'; \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/feedback/toast/toast.component.scss b/projects/ui-essentials/src/lib/components/feedback/toast/toast.component.scss new file mode 100644 index 0000000..fa2fe03 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/feedback/toast/toast.component.scss @@ -0,0 +1,289 @@ +@use "../../../../../../shared-ui/src/styles/semantic/index" as *; + +.ui-toast { + // Core Structure + display: flex; + position: relative; + align-items: flex-start; + width: 100%; + max-width: 400px; + + // Layout & Spacing + padding: $semantic-spacing-component-md; + gap: $semantic-spacing-component-sm; + + // Visual Design + background: $semantic-color-surface-elevated; + border: $semantic-border-width-1 solid $semantic-color-border-primary; + border-radius: $semantic-border-card-radius; + box-shadow: $semantic-shadow-elevation-3; + + // Typography + font-family: map-get($semantic-typography-body-medium, font-family); + font-size: map-get($semantic-typography-body-medium, font-size); + font-weight: map-get($semantic-typography-body-medium, font-weight); + line-height: map-get($semantic-typography-body-medium, line-height); + color: $semantic-color-text-primary; + + // Transitions + transition: all $semantic-motion-duration-normal $semantic-motion-easing-ease; + + // Size Variants + &--sm { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + max-width: 320px; + 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: $semantic-spacing-component-md; + max-width: 480px; + 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; + border-left-width: 4px; + + .ui-toast__icon { + color: $semantic-color-primary; + } + + .ui-toast__title { + color: $semantic-color-primary; + } + } + + &--success { + border-color: $semantic-color-success; + border-left-width: 4px; + + .ui-toast__icon { + color: $semantic-color-success; + } + + .ui-toast__title { + color: $semantic-color-success; + } + } + + &--warning { + border-color: $semantic-color-warning; + border-left-width: 4px; + + .ui-toast__icon { + color: $semantic-color-warning; + } + + .ui-toast__title { + color: $semantic-color-warning; + } + } + + &--danger { + border-color: $semantic-color-danger; + border-left-width: 4px; + + .ui-toast__icon { + color: $semantic-color-danger; + } + + .ui-toast__title { + color: $semantic-color-danger; + } + } + + &--info { + border-color: $semantic-color-info; + border-left-width: 4px; + + .ui-toast__icon { + color: $semantic-color-info; + } + + .ui-toast__title { + color: $semantic-color-info; + } + } + + // BEM Elements + &__icon { + flex-shrink: 0; + color: $semantic-color-text-secondary; + font-size: $semantic-sizing-icon-inline; + 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-label, font-family); + font-size: map-get($semantic-typography-label, font-size); + font-weight: map-get($semantic-typography-label, font-weight); + line-height: map-get($semantic-typography-label, line-height); + color: $semantic-color-text-primary; + + &--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); + } + + // Toast-specific positioning and animations + &--entering { + animation: toast-slide-in $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + &--exiting { + animation: toast-slide-out $semantic-motion-duration-normal $semantic-motion-easing-ease forwards; + } + + // Progress bar for auto-dismiss + &__progress { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 3px; + background: $semantic-color-surface-secondary; + border-radius: 0 0 $semantic-border-card-radius $semantic-border-card-radius; + overflow: hidden; + + &-bar { + height: 100%; + background: currentColor; + width: 100%; + transform-origin: left; + animation: toast-progress var(--duration) linear forwards; + } + } + + &--with-progress { + padding-bottom: calc($semantic-spacing-component-md + 3px); + } + + // Responsive Design + @media (max-width: $semantic-breakpoint-sm - 1) { + padding: $semantic-spacing-component-sm; + gap: $semantic-spacing-component-xs; + max-width: 100%; + + &--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); + } + } +} + +// Keyframe animations +@keyframes toast-slide-in { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +@keyframes toast-slide-out { + from { + opacity: 1; + transform: translateX(0); + } + to { + opacity: 0; + transform: translateX(100%); + } +} + +@keyframes toast-progress { + from { + transform: scaleX(1); + } + to { + transform: scaleX(0); + } +} \ No newline at end of file diff --git a/projects/ui-essentials/src/lib/components/feedback/toast/toast.component.ts b/projects/ui-essentials/src/lib/components/feedback/toast/toast.component.ts new file mode 100644 index 0000000..c6c1eb0 --- /dev/null +++ b/projects/ui-essentials/src/lib/components/feedback/toast/toast.component.ts @@ -0,0 +1,238 @@ +import { Component, Input, Output, EventEmitter, ChangeDetectionStrategy, ViewEncapsulation, OnInit, OnDestroy, ElementRef, 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 ToastSize = 'sm' | 'md' | 'lg'; +type ToastVariant = 'primary' | 'success' | 'warning' | 'danger' | 'info'; +type ToastPosition = 'top-right' | 'top-left' | 'bottom-right' | 'bottom-left' | 'top-center' | 'bottom-center'; + +@Component({ + selector: 'ui-toast', + standalone: true, + imports: [CommonModule, FontAwesomeModule], + changeDetection: ChangeDetectionStrategy.OnPush, + encapsulation: ViewEncapsulation.None, + template: ` +
+ + @if (showIcon && toastIcon) { + + + } + +
+ @if (title) { +

+ {{ title }} +

+ } + +
+ +
+ + @if (actions && actions.length > 0) { +
+ +
+ } +
+ + @if (dismissible) { + + } + + @if (showProgress && autoDismiss && duration > 0) { +
+
+
+
+ } +
+ `, + styleUrl: './toast.component.scss' +}) +export class ToastComponent implements OnInit, OnDestroy { + @Input() size: ToastSize = 'md'; + @Input() variant: ToastVariant = 'primary'; + @Input() title?: string; + @Input() boldTitle = false; + @Input() showIcon = true; + @Input() dismissible = true; + @Input() autoDismiss = true; + @Input() duration = 5000; // 5 seconds + @Input() showProgress = true; + @Input() position: ToastPosition = 'top-right'; + @Input() dismissLabel = 'Dismiss toast'; + @Input() role = 'alert'; + @Input() ariaLive: 'polite' | 'assertive' | 'off' = 'assertive'; + @Input() actions: any[] = []; + + @Output() dismissed = new EventEmitter(); + @Output() expired = new EventEmitter(); + @Output() shown = new EventEmitter(); + @Output() hidden = new EventEmitter(); + + // Icons + readonly faCheckCircle = faCheckCircle; + readonly faExclamationTriangle = faExclamationTriangle; + readonly faExclamationCircle = faExclamationCircle; + readonly faInfoCircle = faInfoCircle; + readonly faTimes = faTimes; + + // Generate unique ID for accessibility + readonly toastId = Math.random().toString(36).substr(2, 9); + + // State management + isEntering = false; + isExiting = false; + private autoDismissTimeout?: any; + private enterTimeout?: any; + private exitTimeout?: any; + private elementRef = inject(ElementRef); + + get toastIcon(): 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; + } + } + + 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(); + } + } + + private startAutoDismissTimer(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + } + + this.autoDismissTimeout = setTimeout(() => { + this.hide(); + this.expired.emit(); + }, this.duration); + } + + private clearTimeouts(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + this.autoDismissTimeout = undefined; + } + + if (this.enterTimeout) { + clearTimeout(this.enterTimeout); + this.enterTimeout = undefined; + } + + if (this.exitTimeout) { + clearTimeout(this.exitTimeout); + this.exitTimeout = undefined; + } + } + + // Pause auto-dismiss on mouse enter + onMouseEnter(): void { + if (this.autoDismissTimeout) { + clearTimeout(this.autoDismissTimeout); + } + } + + // Resume auto-dismiss on mouse leave + onMouseLeave(): void { + if (this.autoDismiss && this.duration > 0 && !this.isExiting) { + this.startAutoDismissTimer(); + } + } +} \ No newline at end of file