import { LitElement, html, css, nothing } from 'lit'; import { property, state } from 'lit/decorators.js'; import type { Position } from '../../../utils/positioning'; import type { AlertVariant } from '../../Alert/core/_Alert'; import '../../Alert/core/Alert'; import '../../shared/CloseButton/CloseButton'; // Event types export type ToastOpenEvent = CustomEvent; export type ToastCloseEvent = CustomEvent; export type ToastDismissEvent = CustomEvent; // Re-export AlertType as ToastType for convenience export type ToastType = AlertVariant; // Props interface following INTERFACE_STANDARDS.md export interface ToastProps { open?: boolean; type?: ToastType; position?: Position; duration?: number; autoDismiss?: boolean; showCloseButton?: boolean; pauseOnHover?: boolean; bordered?: boolean; rounded?: boolean; borderedLeft?: boolean; // Event handlers onToastOpen?: (event: ToastOpenEvent) => void; onToastClose?: (event: ToastCloseEvent) => void; onToastDismiss?: (event: ToastDismissEvent) => void; } /** * Toast Component * * A non-modal notification element that appears at viewport edges or corners * to provide brief, contextual feedback to users. * * @element ag-toast * * @fires toast-open - Dispatched when toast becomes visible * @fires toast-close - Dispatched when toast is dismissed * @fires toast-dismiss - Dispatched when auto-dismiss timer completes * * @slot - Default slot for toast message content * * @csspart ag-toast - The outer container * @csspart ag-toast-content - The content wrapper */ export class Toast extends LitElement implements ToastProps { @property({ type: Boolean, reflect: true }) declare open: boolean; @property({ type: String }) declare type: ToastType; @property({ type: String, reflect: true }) declare position: Position; @property({ type: Number }) declare duration: number; @property({ type: Boolean }) declare autoDismiss: boolean; @property({ type: Boolean }) declare showCloseButton: boolean; @property({ type: Boolean }) declare pauseOnHover: boolean; @property({ type: Boolean }) declare bordered: boolean; @property({ type: Boolean }) declare rounded: boolean; @property({ type: Boolean }) declare borderedLeft: boolean; @property({ attribute: false }) declare onToastOpen?: (event: ToastOpenEvent) => void; @property({ attribute: false }) declare onToastClose?: (event: ToastCloseEvent) => void; @property({ attribute: false }) declare onToastDismiss?: (event: ToastDismissEvent) => void; @state() private _isHovered = false; private _autoDismissTimer: number | null = null; private _timerStartTime: number | null = null; private _remainingTime: number | null = null; constructor() { super(); this.open = false; this.type = 'default'; this.position = 'top-end'; this.duration = 5000; this.autoDismiss = true; this.showCloseButton = true; this.pauseOnHover = true; this.bordered = false; this.rounded = true; this.borderedLeft = false; } connectedCallback() { super.connectedCallback(); } disconnectedCallback() { super.disconnectedCallback(); this._clearTimer(); } willUpdate(changedProperties: Map) { if (changedProperties.has('open')) { const previousOpen = changedProperties.get('open'); if (this.open && !previousOpen) { // Opening - Dual-dispatch pattern const openEvent = new CustomEvent('toast-open', { bubbles: true, composed: true, }); this.dispatchEvent(openEvent); this.onToastOpen?.(openEvent); if (this.autoDismiss && this.duration > 0) { this._startTimer(); } } else if (!this.open && previousOpen) { // Closing - Dual-dispatch pattern this._clearTimer(); const closeEvent = new CustomEvent('toast-close', { bubbles: true, composed: true, }); this.dispatchEvent(closeEvent); this.onToastClose?.(closeEvent); } } } private _startTimer() { this._clearTimer(); this._timerStartTime = Date.now(); this._remainingTime = this.duration; this._autoDismissTimer = window.setTimeout(() => { this._handleAutoDismiss(); }, this.duration); } private _pauseTimer() { if (!this._autoDismissTimer || !this._timerStartTime) return; const elapsed = Date.now() - this._timerStartTime; this._remainingTime = Math.max(0, this.duration - elapsed); this._clearTimer(); } private _resumeTimer() { if (!this.autoDismiss || this._remainingTime === null) return; this._timerStartTime = Date.now(); this._autoDismissTimer = window.setTimeout(() => { this._handleAutoDismiss(); }, this._remainingTime); } private _clearTimer() { if (this._autoDismissTimer) { window.clearTimeout(this._autoDismissTimer); this._autoDismissTimer = null; this._timerStartTime = null; } } private _handleAutoDismiss() { // Dual-dispatch pattern for dismiss event const dismissEvent = new CustomEvent('toast-dismiss', { bubbles: true, composed: true, }); this.dispatchEvent(dismissEvent); this.onToastDismiss?.(dismissEvent); this.open = false; } private _handleCloseButtonClick = () => { this._clearTimer(); this.open = false; }; private _handleMouseEnter = () => { if (this.pauseOnHover && this.autoDismiss) { this._isHovered = true; this._pauseTimer(); } }; private _handleMouseLeave = () => { if (this.pauseOnHover && this.autoDismiss) { this._isHovered = false; this._resumeTimer(); } }; private _handleKeydown = (event: KeyboardEvent) => { if (event.key === 'Escape' && this.showCloseButton) { event.preventDefault(); this._handleCloseButtonClick(); } }; private _isUrgentType(): boolean { return this.type === 'error' || this.type === 'danger' || this.type === 'warning'; } static styles = css` :host { display: block; visibility: hidden; position: fixed; z-index: var(--ag-z-index-toast, 1000); pointer-events: none; } :host([open]) { visibility: visible; pointer-events: auto; } /* Edge positions - full width/height */ :host([position="top"]) { top: var(--ag-space-4); inset-inline: 0; width: 100%; } :host([position="bottom"]) { bottom: var(--ag-space-4); inset-inline: 0; width: 100%; } :host([position="start"]) { top: 0; inset-inline-start: var(--ag-space-4); bottom: 0; height: 100%; } :host([position="end"]) { top: 0; inset-inline-end: var(--ag-space-4); bottom: 0; height: 100%; } /* Corner positions - constrained size */ :host([position="top-start"]) { top: var(--ag-space-4); inset-inline-start: var(--ag-space-4); } :host([position="top-end"]) { top: var(--ag-space-4); inset-inline-end: var(--ag-space-4); } :host([position="bottom-start"]) { bottom: var(--ag-space-4); inset-inline-start: var(--ag-space-4); } :host([position="bottom-end"]) { bottom: var(--ag-space-4); inset-inline-end: var(--ag-space-4); } /* Toast container with animations */ .toast-container { position: relative; opacity: 0; transform: translateY(-100%); transition: opacity var(--ag-motion-fast) ease-out, transform var(--ag-motion-fast) ease-out; } :host([open]) .toast-container { opacity: 1; transform: translateY(0); } /* Transform variations based on position */ :host([position="bottom"]) .toast-container, :host([position="bottom-start"]) .toast-container, :host([position="bottom-end"]) .toast-container { transform: translateY(100%); } :host([position="start"]) .toast-container { transform: translateX(-100%); } :host([position="end"]) .toast-container { transform: translateX(100%); } /* RTL Support - flip transform directions for start/end positions */ :host-context([dir="rtl"]):host([position="start"]) .toast-container { transform: translateX(100%); } :host-context([dir="rtl"]):host([position="end"]) .toast-container { transform: translateX(-100%); } :host([position="bottom"][open]) .toast-container, :host([position="bottom-start"][open]) .toast-container, :host([position="bottom-end"][open]) .toast-container, :host([position="start"][open]) .toast-container, :host([position="end"][open]) .toast-container { transform: translateX(0) translateY(0); } /* Size constraints for corner positions */ :host([position="top-start"]) .toast-container, :host([position="top-end"]) .toast-container, :host([position="bottom-start"]) .toast-container, :host([position="bottom-end"]) .toast-container { max-inline-size: 400px; max-block-size: 200px; } /* Inner layout for content and close button */ .toast-inner-layout { display: flex; align-items: center; gap: var(--ag-space-3); } .toast-content { flex: 1; min-width: 0; /* Allows content to shrink and wrap */ display: flex; align-items: center; gap: var(--ag-space-2); } ag-close-button { flex-shrink: 0; } @media (prefers-reduced-motion: reduce) { .toast-container { transition: opacity var(--ag-motion-fast) ease; transform: none !important; } :host([open]) .toast-container { transform: none !important; } } `; render() { const role = this._isUrgentType() ? 'alert' : 'status'; const ariaLive = this._isUrgentType() ? 'assertive' : 'polite'; return html` ${this.showCloseButton ? html` ` : nothing} `; } }