stylescape
Version:
Stylescape is a visual identity framework developed by Scape Agency.
475 lines (416 loc) • 15 kB
text/typescript
// ============================================================================
// Stylescape | Notification Manager
// ============================================================================
// Toast notifications and alerts with stacking, positioning, and animations.
// Supports data-ss-notification attributes for declarative configuration.
// ============================================================================
/**
* Notification types
*/
export type NotificationType = "success" | "error" | "warning" | "info";
/**
* Notification position
*/
export type NotificationPosition =
| "top-right"
| "top-left"
| "top-center"
| "bottom-right"
| "bottom-left"
| "bottom-center";
/**
* Single notification options
*/
export interface NotificationOptions {
/** Notification type */
type?: NotificationType;
/** Title text */
title?: string;
/** Message text */
message: string;
/** Auto-dismiss duration in ms (0 for manual) */
duration?: number;
/** Allow manual close */
closable?: boolean;
/** Icon class or SVG */
icon?: string;
/** Custom CSS class */
className?: string;
/** Action button text */
actionText?: string;
/** Action button callback */
onAction?: () => void;
/** Callback on close */
onClose?: () => void;
/** Progress bar */
showProgress?: boolean;
}
/**
* Global notification manager options
*/
export interface NotificationManagerOptions {
/** Container position */
position?: NotificationPosition;
/** Max visible notifications */
maxNotifications?: number;
/** CSS class prefix */
cssClass?: string;
/** Animation duration */
animationDuration?: number;
/** Default duration for notifications */
defaultDuration?: number;
/** Stack order: newest on top */
newestOnTop?: boolean;
/** Pause on hover */
pauseOnHover?: boolean;
}
interface NotificationInstance {
id: string;
element: HTMLElement;
options: NotificationOptions;
timeout: ReturnType<typeof setTimeout> | null;
startTime: number;
remainingTime: number;
}
/**
* Toast notification manager with stacking and animations.
*
* @example JavaScript
* ```typescript
* const notifications = new NotificationManager({
* position: "top-right",
* maxNotifications: 5
* })
*
* notifications.success("File saved successfully!")
* notifications.error("An error occurred", { duration: 5000 })
* notifications.info("New update available", {
* actionText: "Update Now",
* onAction: () => window.location.reload()
* })
* ```
*
* @example HTML with data-ss (container)
* ```html
* <div data-ss="notification-container"
* data-ss-notification-position="top-right"
* data-ss-notification-max="5">
* </div>
* ```
*/
export class NotificationManager {
private static instance: NotificationManager | null = null;
private container: HTMLElement | null = null;
private notifications: Map<string, NotificationInstance> = new Map();
private options: Required<NotificationManagerOptions>;
constructor(options: NotificationManagerOptions = {}) {
this.options = {
position: options.position ?? "top-right",
maxNotifications: options.maxNotifications ?? 5,
cssClass: options.cssClass ?? "ss-notification",
animationDuration: options.animationDuration ?? 300,
defaultDuration: options.defaultDuration ?? 4000,
newestOnTop: options.newestOnTop ?? true,
pauseOnHover: options.pauseOnHover ?? true,
};
this.createContainer();
// Singleton pattern for global access
if (!NotificationManager.instance) {
NotificationManager.instance = this;
}
}
// ========================================================================
// Public Methods - Shortcuts
// ========================================================================
/**
* Show success notification
*/
public success(
message: string,
options?: Partial<NotificationOptions>,
): string {
return this.show({ ...options, message, type: "success" });
}
/**
* Show error notification
*/
public error(
message: string,
options?: Partial<NotificationOptions>,
): string {
return this.show({
...options,
message,
type: "error",
duration: options?.duration ?? 0,
});
}
/**
* Show warning notification
*/
public warning(
message: string,
options?: Partial<NotificationOptions>,
): string {
return this.show({ ...options, message, type: "warning" });
}
/**
* Show info notification
*/
public info(
message: string,
options?: Partial<NotificationOptions>,
): string {
return this.show({ ...options, message, type: "info" });
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Show a notification
*/
public show(options: NotificationOptions): string {
// Remove oldest if at max
if (this.notifications.size >= this.options.maxNotifications) {
const oldest = this.options.newestOnTop
? Array.from(this.notifications.keys()).pop()
: Array.from(this.notifications.keys()).shift();
if (oldest) this.dismiss(oldest);
}
const id = `notification-${Date.now()}-${Math.random().toString(36).slice(2)}`;
const element = this.createNotification(id, options);
const duration = options.duration ?? this.options.defaultDuration;
const instance: NotificationInstance = {
id,
element,
options,
timeout: null,
startTime: Date.now(),
remainingTime: duration,
};
this.notifications.set(id, instance);
// Add to container
if (this.options.newestOnTop) {
this.container?.prepend(element);
} else {
this.container?.appendChild(element);
}
// Trigger animation
requestAnimationFrame(() => {
element.classList.add(`${this.options.cssClass}--visible`);
});
// Auto-dismiss
if (duration > 0) {
instance.timeout = setTimeout(() => this.dismiss(id), duration);
}
return id;
}
/**
* Dismiss a notification by ID
*/
public dismiss(id: string): void {
const instance = this.notifications.get(id);
if (!instance) return;
// Clear timeout
if (instance.timeout) {
clearTimeout(instance.timeout);
}
// Animate out
instance.element.classList.remove(`${this.options.cssClass}--visible`);
instance.element.classList.add(`${this.options.cssClass}--removing`);
setTimeout(() => {
instance.element.remove();
this.notifications.delete(id);
instance.options.onClose?.();
}, this.options.animationDuration);
}
/**
* Dismiss all notifications
*/
public dismissAll(): void {
this.notifications.forEach((_, id) => this.dismiss(id));
}
/**
* Get notification count
*/
public get count(): number {
return this.notifications.size;
}
/**
* Destroy the manager
*/
public destroy(): void {
this.dismissAll();
this.container?.remove();
this.container = null;
if (NotificationManager.instance === this) {
NotificationManager.instance = null;
}
}
// ========================================================================
// Static Methods
// ========================================================================
/**
* Get the global instance
*/
public static getInstance(): NotificationManager {
if (!NotificationManager.instance) {
NotificationManager.instance = new NotificationManager();
}
return NotificationManager.instance;
}
/**
* Initialize from data-ss="notification-container"
*/
public static init(): NotificationManager {
const container = document.querySelector<HTMLElement>(
'[data-ss="notification-container"]',
);
return new NotificationManager({
position: container?.dataset
.ssNotificationPosition as NotificationPosition,
maxNotifications: container?.dataset.ssNotificationMax
? parseInt(container.dataset.ssNotificationMax, 10)
: undefined,
});
}
// ========================================================================
// Private Methods
// ========================================================================
private createContainer(): void {
// Check for existing container
this.container = document.querySelector<HTMLElement>(
`[data-ss="notification-container"], .${this.options.cssClass}-container`,
);
if (!this.container) {
this.container = document.createElement("div");
this.container.className = `${this.options.cssClass}-container`;
document.body.appendChild(this.container);
}
this.container.classList.add(
`${this.options.cssClass}-container--${this.options.position}`,
);
this.container.setAttribute("role", "region");
this.container.setAttribute("aria-label", "Notifications");
this.container.setAttribute("aria-live", "polite");
}
private createNotification(
id: string,
options: NotificationOptions,
): HTMLElement {
const el = document.createElement("div");
el.className = `${this.options.cssClass} ${this.options.cssClass}--${options.type || "info"}`;
if (options.className) {
el.classList.add(options.className);
}
el.setAttribute("role", "alert");
el.setAttribute("aria-live", "assertive");
el.setAttribute("data-notification-id", id);
// Icon
const iconHtml = options.icon
? `<span class="${this.options.cssClass}__icon">${options.icon}</span>`
: this.getDefaultIcon(options.type || "info");
// Title
const titleHtml = options.title
? `<div class="${this.options.cssClass}__title">${options.title}</div>`
: "";
// Action button
const actionHtml = options.actionText
? `<button type="button" class="${this.options.cssClass}__action">${options.actionText}</button>`
: "";
// Close button
const closeHtml =
options.closable !== false
? `<button type="button" class="${this.options.cssClass}__close" aria-label="Close">×</button>`
: "";
// Progress bar
const progressHtml =
options.showProgress &&
(options.duration ?? this.options.defaultDuration) > 0
? `<div class="${this.options.cssClass}__progress"><div class="${this.options.cssClass}__progress-bar"></div></div>`
: "";
el.innerHTML = `
${iconHtml}
<div class="${this.options.cssClass}__content">
${titleHtml}
<div class="${this.options.cssClass}__message">${options.message}</div>
${actionHtml}
</div>
${closeHtml}
${progressHtml}
`;
// Event listeners
const closeBtn = el.querySelector(`.${this.options.cssClass}__close`);
closeBtn?.addEventListener("click", () => this.dismiss(id));
const actionBtn = el.querySelector(
`.${this.options.cssClass}__action`,
);
actionBtn?.addEventListener("click", () => {
options.onAction?.();
this.dismiss(id);
});
// Pause on hover
if (this.options.pauseOnHover) {
el.addEventListener("mouseenter", () => this.pauseTimeout(id));
el.addEventListener("mouseleave", () => this.resumeTimeout(id));
}
// Progress bar animation
if (options.showProgress) {
const duration = options.duration ?? this.options.defaultDuration;
const progressBar = el.querySelector<HTMLElement>(
`.${this.options.cssClass}__progress-bar`,
);
if (progressBar && duration > 0) {
progressBar.style.transition = `width ${duration}ms linear`;
requestAnimationFrame(() => {
progressBar.style.width = "0%";
});
}
}
return el;
}
private getDefaultIcon(type: NotificationType): string {
const icons: Record<NotificationType, string> = {
success: `<span class="${this.options.cssClass}__icon">✓</span>`,
error: `<span class="${this.options.cssClass}__icon">✕</span>`,
warning: `<span class="${this.options.cssClass}__icon">⚠</span>`,
info: `<span class="${this.options.cssClass}__icon">ℹ</span>`,
};
return icons[type];
}
private pauseTimeout(id: string): void {
const instance = this.notifications.get(id);
if (!instance || !instance.timeout) return;
clearTimeout(instance.timeout);
instance.remainingTime -= Date.now() - instance.startTime;
// Pause progress bar
const progressBar = instance.element.querySelector<HTMLElement>(
`.${this.options.cssClass}__progress-bar`,
);
if (progressBar) {
const computed = getComputedStyle(progressBar);
progressBar.style.width = computed.width;
progressBar.style.transition = "none";
}
}
private resumeTimeout(id: string): void {
const instance = this.notifications.get(id);
if (!instance || instance.remainingTime <= 0) return;
instance.startTime = Date.now();
instance.timeout = setTimeout(
() => this.dismiss(id),
instance.remainingTime,
);
// Resume progress bar
const progressBar = instance.element.querySelector<HTMLElement>(
`.${this.options.cssClass}__progress-bar`,
);
if (progressBar) {
progressBar.style.transition = `width ${instance.remainingTime}ms linear`;
requestAnimationFrame(() => {
progressBar.style.width = "0%";
});
}
}
}
export default NotificationManager;