mirror of
https://github.com/arsvendg/Stirling-PDF.git
synced 2026-07-16 03:20:46 +02:00
Restructure frontend code to allow for extensions (#4721)
# Description of Changes Move frontend code into `core` folder and add infrastructure for `proprietary` folder to include premium, non-OSS features
This commit is contained in:
@@ -0,0 +1,309 @@
|
||||
# Toast Component
|
||||
|
||||
A global notification system with expandable content, progress tracking, and smart error coalescing. Provides an imperative API for showing success, error, warning, and neutral notifications with customizable content and behavior.
|
||||
|
||||
---
|
||||
|
||||
## Highlights
|
||||
|
||||
* 🎯 **Global System**: Imperative API accessible from anywhere in the app via `alert()` function.
|
||||
* 🎨 **Four Alert Types**: Success (green), Error (red), Warning (yellow), Neutral (theme-aware).
|
||||
* 📱 **Expandable Content**: Collapsible toasts with chevron controls and smooth animations.
|
||||
* ⚡ **Smart Coalescing**: Duplicate error toasts merge with count badges (e.g., "Server error 4").
|
||||
* 📊 **Progress Tracking**: Built-in progress bars with completion animations.
|
||||
* 🎛️ **Customizable**: Rich JSX content, buttons with callbacks, custom icons.
|
||||
* 🌙 **Themeable**: Uses CSS variables; supports light/dark mode out of the box.
|
||||
* ♿ **Accessible**: Proper ARIA roles, keyboard navigation, and screen reader support.
|
||||
* 🔄 **Auto-dismiss**: Configurable duration with persistent popup option.
|
||||
* 📍 **Positioning**: Four corner positions with proper stacking.
|
||||
|
||||
---
|
||||
|
||||
## Behavior
|
||||
|
||||
### Default
|
||||
* **Auto-dismiss**: Toasts disappear after 6 seconds unless `isPersistentPopup: true`.
|
||||
* **Expandable**: Click chevron to expand/collapse body content (default: collapsed).
|
||||
* **Coalescing**: Identical error toasts merge with count badges.
|
||||
* **Progress**: Progress bars always visible when present, even when collapsed.
|
||||
|
||||
### Error Handling
|
||||
* **Network Errors**: Automatically caught by Axios and fetch interceptors.
|
||||
* **Friendly Fallbacks**: Shows "There was an error processing your request" for unhelpful backend responses.
|
||||
* **Smart Titles**: "Server error" for 5xx, "Request error" for 4xx, "Network error" for others.
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
The toast system is already integrated at the app root. No additional setup required.
|
||||
|
||||
```tsx
|
||||
import { alert, updateToast, dismissToast } from '@/components/toast';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Basic Usage
|
||||
|
||||
### Simple Notifications
|
||||
|
||||
```tsx
|
||||
// Success notification
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: 'File processed successfully',
|
||||
body: 'Your document has been converted to PDF.'
|
||||
});
|
||||
|
||||
// Error notification
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Processing failed',
|
||||
body: 'Unable to process the selected files.'
|
||||
});
|
||||
|
||||
// Warning notification
|
||||
alert({
|
||||
alertType: 'warning',
|
||||
title: 'Low disk space',
|
||||
body: 'Consider freeing up some storage space.'
|
||||
});
|
||||
|
||||
// Neutral notification
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
title: 'Information',
|
||||
body: 'This is a neutral notification.'
|
||||
});
|
||||
```
|
||||
|
||||
### With Custom Content
|
||||
|
||||
```tsx
|
||||
// Rich JSX content with buttons
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: 'Download complete',
|
||||
body: (
|
||||
<div>
|
||||
<p>File saved to Downloads folder</p>
|
||||
<button onClick={() => openFolder()}>Open folder</button>
|
||||
</div>
|
||||
),
|
||||
buttonText: 'View file',
|
||||
buttonCallback: () => openFile(),
|
||||
isPersistentPopup: true
|
||||
});
|
||||
```
|
||||
|
||||
### Progress Tracking
|
||||
|
||||
```tsx
|
||||
// Show progress
|
||||
const toastId = alert({
|
||||
alertType: 'neutral',
|
||||
title: 'Processing files...',
|
||||
body: 'Converting your documents',
|
||||
progressBarPercentage: 0
|
||||
});
|
||||
|
||||
// Update progress
|
||||
updateToast(toastId, { progressBarPercentage: 50 });
|
||||
|
||||
// Complete with success
|
||||
updateToast(toastId, {
|
||||
alertType: 'success',
|
||||
title: 'Processing complete',
|
||||
body: 'All files converted successfully',
|
||||
progressBarPercentage: 100
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Positioning
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Connection lost',
|
||||
body: 'Please check your internet connection.',
|
||||
location: 'top-right'
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API
|
||||
|
||||
### `alert(options: ToastOptions)`
|
||||
|
||||
The primary function for showing toasts.
|
||||
|
||||
```ts
|
||||
interface ToastOptions {
|
||||
alertType?: 'success' | 'error' | 'warning' | 'neutral';
|
||||
title: string;
|
||||
body?: React.ReactNode;
|
||||
buttonText?: string;
|
||||
buttonCallback?: () => void;
|
||||
isPersistentPopup?: boolean;
|
||||
location?: 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
icon?: React.ReactNode;
|
||||
progressBarPercentage?: number; // 0-1 as fraction or 0-100 as percent
|
||||
durationMs?: number;
|
||||
id?: string;
|
||||
expandable?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### `updateToast(id: string, options: Partial<ToastOptions>)`
|
||||
|
||||
Update an existing toast.
|
||||
|
||||
```tsx
|
||||
const toastId = alert({ title: 'Processing...', progressBarPercentage: 0 });
|
||||
updateToast(toastId, { progressBarPercentage: 75 });
|
||||
```
|
||||
|
||||
### `dismissToast(id: string)`
|
||||
|
||||
Dismiss a specific toast.
|
||||
|
||||
```tsx
|
||||
dismissToast(toastId);
|
||||
```
|
||||
|
||||
### `dismissAllToasts()`
|
||||
|
||||
Dismiss all visible toasts.
|
||||
|
||||
```tsx
|
||||
dismissAllToasts();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Alert Types
|
||||
|
||||
| Type | Color | Icon | Use Case |
|
||||
|------|-------|------|----------|
|
||||
| `success` | Green | ✓ | Successful operations, completions |
|
||||
| `error` | Red | ✗ | Failures, errors, exceptions |
|
||||
| `warning` | Yellow | ⚠ | Warnings, cautions, low resources |
|
||||
| `neutral` | Theme | ℹ | Information, general messages |
|
||||
|
||||
---
|
||||
|
||||
## Positioning
|
||||
|
||||
| Location | Description |
|
||||
|----------|-------------|
|
||||
| `top-left` | Top-left corner |
|
||||
| `top-right` | Top-right corner |
|
||||
| `bottom-left` | Bottom-left corner |
|
||||
| `bottom-right` | Bottom-right corner (default) |
|
||||
|
||||
---
|
||||
|
||||
## Accessibility
|
||||
|
||||
* Toasts use `role="status"` for screen readers.
|
||||
* Chevron and close buttons have proper `aria-label` attributes.
|
||||
* Keyboard navigation supported (Escape to dismiss).
|
||||
* Focus management for interactive content.
|
||||
|
||||
---
|
||||
|
||||
## Examples
|
||||
|
||||
### File Processing Workflow
|
||||
|
||||
```tsx
|
||||
// Start processing
|
||||
const toastId = alert({
|
||||
alertType: 'neutral',
|
||||
title: 'Processing files...',
|
||||
body: 'Converting 5 documents',
|
||||
progressBarPercentage: 0,
|
||||
isPersistentPopup: true
|
||||
});
|
||||
|
||||
// Update progress
|
||||
updateToast(toastId, { progressBarPercentage: 30 });
|
||||
updateToast(toastId, { progressBarPercentage: 60 });
|
||||
|
||||
// Complete successfully
|
||||
updateToast(toastId, {
|
||||
alertType: 'success',
|
||||
title: 'Processing complete',
|
||||
body: 'All 5 documents converted successfully',
|
||||
progressBarPercentage: 100,
|
||||
isPersistentPopup: false
|
||||
});
|
||||
```
|
||||
|
||||
### Error with Action
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Upload failed',
|
||||
body: 'File size exceeds the 10MB limit.',
|
||||
buttonText: 'Try again',
|
||||
buttonCallback: () => retryUpload(),
|
||||
isPersistentPopup: true
|
||||
});
|
||||
```
|
||||
|
||||
### Non-expandable Toast
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'success',
|
||||
title: 'Settings saved',
|
||||
body: 'Your preferences have been updated.',
|
||||
expandable: false,
|
||||
durationMs: 3000
|
||||
});
|
||||
```
|
||||
|
||||
### Custom Icon
|
||||
|
||||
```tsx
|
||||
alert({
|
||||
alertType: 'neutral',
|
||||
title: 'New feature available',
|
||||
body: 'Check out the latest updates.',
|
||||
icon: <LocalIcon icon="star" />
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integration
|
||||
|
||||
### Network Error Handling
|
||||
|
||||
The toast system automatically catches network errors from Axios and fetch requests:
|
||||
|
||||
```tsx
|
||||
// These automatically show error toasts
|
||||
axios.post('/api/convert', formData);
|
||||
fetch('/api/process', { method: 'POST', body: data });
|
||||
```
|
||||
|
||||
### Manual Error Handling
|
||||
|
||||
```tsx
|
||||
try {
|
||||
await processFiles();
|
||||
alert({ alertType: 'success', title: 'Files processed' });
|
||||
} catch (error) {
|
||||
alert({
|
||||
alertType: 'error',
|
||||
title: 'Processing failed',
|
||||
body: error.message
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import React, { createContext, useCallback, useContext, useMemo, useRef, useState, useEffect } from 'react';
|
||||
import { ToastApi, ToastInstance, ToastOptions } from '@app/components/toast/types';
|
||||
|
||||
function normalizeProgress(value: number | undefined): number | undefined {
|
||||
if (typeof value !== 'number' || Number.isNaN(value)) return undefined;
|
||||
// Accept 0..1 as fraction or 0..100 as percent
|
||||
if (value <= 1) return Math.max(0, Math.min(1, value)) * 100;
|
||||
return Math.max(0, Math.min(100, value));
|
||||
}
|
||||
|
||||
function generateId() {
|
||||
return `toast_${Math.random().toString(36).slice(2, 9)}`;
|
||||
}
|
||||
|
||||
type DefaultOpts = Required<Pick<ToastOptions, 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>> &
|
||||
Partial<Omit<ToastOptions, 'id' | 'alertType' | 'title' | 'isPersistentPopup' | 'location' | 'durationMs'>>;
|
||||
|
||||
const defaultOptions: DefaultOpts = {
|
||||
alertType: 'neutral',
|
||||
title: '',
|
||||
isPersistentPopup: false,
|
||||
location: 'bottom-right',
|
||||
durationMs: 6000,
|
||||
};
|
||||
|
||||
interface ToastContextShape extends ToastApi {
|
||||
toasts: ToastInstance[];
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextShape | null>(null);
|
||||
|
||||
export function useToast() {
|
||||
const ctx = useContext(ToastContext);
|
||||
if (!ctx) throw new Error('useToast must be used within ToastProvider');
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
||||
const [toasts, setToasts] = useState<ToastInstance[]>([]);
|
||||
const timers = useRef<Record<string, number>>({});
|
||||
|
||||
const scheduleAutoDismiss = useCallback((toast: ToastInstance) => {
|
||||
if (toast.isPersistentPopup) return;
|
||||
window.clearTimeout(timers.current[toast.id]);
|
||||
timers.current[toast.id] = window.setTimeout(() => {
|
||||
setToasts(prev => prev.filter(t => t.id !== toast.id));
|
||||
}, toast.durationMs);
|
||||
}, []);
|
||||
|
||||
const show = useCallback<ToastApi['show']>((options) => {
|
||||
const id = options.id || generateId();
|
||||
const hasButton = !!(options.buttonText && options.buttonCallback);
|
||||
const merged: ToastInstance = {
|
||||
...defaultOptions,
|
||||
...options,
|
||||
id,
|
||||
progress: normalizeProgress(options.progressBarPercentage),
|
||||
justCompleted: false,
|
||||
expandable: hasButton ? false : (options.expandable !== false),
|
||||
isExpanded: hasButton ? true : (options.expandable === false ? true : (options.alertType === 'error' ? true : false)),
|
||||
createdAt: Date.now(),
|
||||
} as ToastInstance;
|
||||
setToasts(prev => {
|
||||
// Coalesce duplicates by alertType + title + body text if no explicit id was provided
|
||||
if (!options.id) {
|
||||
const bodyText = typeof merged.body === 'string' ? merged.body : '';
|
||||
const existingIndex = prev.findIndex(t => t.alertType === merged.alertType && t.title === merged.title && (typeof t.body === 'string' ? t.body : '') === bodyText);
|
||||
if (existingIndex !== -1) {
|
||||
const updated = [...prev];
|
||||
const existing = updated[existingIndex];
|
||||
const nextCount = (existing.count ?? 1) + 1;
|
||||
updated[existingIndex] = { ...existing, count: nextCount, createdAt: Date.now() };
|
||||
return updated;
|
||||
}
|
||||
}
|
||||
const next = [...prev.filter(t => t.id !== id), merged];
|
||||
return next;
|
||||
});
|
||||
scheduleAutoDismiss(merged);
|
||||
return id;
|
||||
}, [scheduleAutoDismiss]);
|
||||
|
||||
const update = useCallback<ToastApi['update']>((id, updates) => {
|
||||
setToasts(prev => prev.map(t => {
|
||||
if (t.id !== id) return t;
|
||||
const progress = updates.progressBarPercentage !== undefined
|
||||
? normalizeProgress(updates.progressBarPercentage)
|
||||
: t.progress;
|
||||
|
||||
const next: ToastInstance = {
|
||||
...t,
|
||||
...updates,
|
||||
progress,
|
||||
} as ToastInstance;
|
||||
|
||||
// Detect completion
|
||||
if (typeof progress === 'number' && progress >= 100 && !t.justCompleted) {
|
||||
// On completion: finalize type as success unless explicitly provided otherwise
|
||||
next.justCompleted = false;
|
||||
if (!updates.alertType) {
|
||||
next.alertType = 'success';
|
||||
}
|
||||
}
|
||||
|
||||
return next;
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const updateProgress = useCallback<ToastApi['updateProgress']>((id, progress) => {
|
||||
update(id, { progressBarPercentage: progress });
|
||||
}, [update]);
|
||||
|
||||
const dismiss = useCallback<ToastApi['dismiss']>((id) => {
|
||||
setToasts(prev => prev.filter(t => t.id !== id));
|
||||
window.clearTimeout(timers.current[id]);
|
||||
delete timers.current[id];
|
||||
}, []);
|
||||
|
||||
const dismissAll = useCallback<ToastApi['dismissAll']>(() => {
|
||||
setToasts([]);
|
||||
Object.values(timers.current).forEach(t => window.clearTimeout(t));
|
||||
timers.current = {};
|
||||
}, []);
|
||||
|
||||
const value = useMemo<ToastContextShape>(() => ({
|
||||
toasts,
|
||||
show,
|
||||
update,
|
||||
updateProgress,
|
||||
dismiss,
|
||||
dismissAll,
|
||||
}), [toasts, show, update, updateProgress, dismiss, dismissAll]);
|
||||
|
||||
// Handle expand/collapse toggles from renderer without widening API
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
const detail = (e as CustomEvent).detail as { id: string } | undefined;
|
||||
if (!detail?.id) return;
|
||||
setToasts(prev => prev.map(t => t.id === detail.id ? { ...t, isExpanded: !t.isExpanded } : t));
|
||||
};
|
||||
window.addEventListener('toast:toggle', handler as EventListener);
|
||||
return () => window.removeEventListener('toast:toggle', handler as EventListener);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>{children}</ToastContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/* Toast Container Styles */
|
||||
.toast-container {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.toast-container--top-left {
|
||||
top: 16px;
|
||||
left: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast-container--top-right {
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.toast-container--bottom-left {
|
||||
bottom: 16px;
|
||||
left: 16px;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
.toast-container--bottom-right {
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
flex-direction: column-reverse;
|
||||
}
|
||||
|
||||
/* Toast Item Styles */
|
||||
.toast-item {
|
||||
min-width: 320px;
|
||||
max-width: 560px;
|
||||
box-shadow: var(--shadow-lg);
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Toast Alert Type Colors */
|
||||
.toast-item--success {
|
||||
background: var(--color-green-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-green-400);
|
||||
}
|
||||
|
||||
.toast-item--error {
|
||||
background: var(--color-red-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-red-400);
|
||||
}
|
||||
|
||||
.toast-item--warning {
|
||||
background: var(--color-yellow-100);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--color-yellow-400);
|
||||
}
|
||||
|
||||
.toast-item--neutral {
|
||||
background: var(--bg-surface);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-default);
|
||||
}
|
||||
|
||||
/* Toast Header Row */
|
||||
.toast-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.toast-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-title-container {
|
||||
font-weight: 700;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.toast-count-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 20px;
|
||||
height: 20px;
|
||||
padding: 0 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(0, 0, 0, 0.08);
|
||||
color: inherit;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.toast-controls {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toast-button {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.toast-expand-button {
|
||||
transform: rotate(0deg);
|
||||
transition: transform 160ms ease;
|
||||
}
|
||||
|
||||
.toast-expand-button--expanded {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
/* Progress Bar */
|
||||
.toast-progress-container {
|
||||
margin-top: 8px;
|
||||
height: 6px;
|
||||
background: var(--bg-muted);
|
||||
border-radius: 999px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.toast-progress-bar {
|
||||
height: 100%;
|
||||
transition: width 160ms ease;
|
||||
}
|
||||
|
||||
.toast-progress-bar--success {
|
||||
background: var(--color-green-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--error {
|
||||
background: var(--color-red-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--warning {
|
||||
background: var(--color-yellow-500);
|
||||
}
|
||||
|
||||
.toast-progress-bar--neutral {
|
||||
background: var(--color-gray-500);
|
||||
}
|
||||
|
||||
/* Toast Body */
|
||||
.toast-body {
|
||||
font-size: 14px;
|
||||
opacity: 0.9;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* Toast Action Button */
|
||||
.toast-action-container {
|
||||
margin-top: 12px;
|
||||
display: flex;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.toast-action-button {
|
||||
padding: 8px 12px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid;
|
||||
background: transparent;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.toast-action-button--success {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-green-400);
|
||||
}
|
||||
|
||||
.toast-action-button--error {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-red-400);
|
||||
}
|
||||
|
||||
.toast-action-button--warning {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--color-yellow-400);
|
||||
}
|
||||
|
||||
.toast-action-button--neutral {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-default);
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useToast } from '@app/components/toast/ToastContext';
|
||||
import { ToastInstance, ToastLocation } from '@app/components/toast/types';
|
||||
import { LocalIcon } from '@app/components/shared/LocalIcon';
|
||||
import '@app/components/toast/ToastRenderer.css';
|
||||
|
||||
const locationToClass: Record<ToastLocation, string> = {
|
||||
'top-left': 'toast-container--top-left',
|
||||
'top-right': 'toast-container--top-right',
|
||||
'bottom-left': 'toast-container--bottom-left',
|
||||
'bottom-right': 'toast-container--bottom-right',
|
||||
};
|
||||
|
||||
function getToastItemClass(t: ToastInstance): string {
|
||||
return `toast-item toast-item--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getProgressBarClass(t: ToastInstance): string {
|
||||
return `toast-progress-bar toast-progress-bar--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getActionButtonClass(t: ToastInstance): string {
|
||||
return `toast-action-button toast-action-button--${t.alertType}`;
|
||||
}
|
||||
|
||||
function getDefaultIconName(t: ToastInstance): string {
|
||||
switch (t.alertType) {
|
||||
case 'success':
|
||||
return 'check-circle-rounded';
|
||||
case 'error':
|
||||
return 'close-rounded';
|
||||
case 'warning':
|
||||
return 'warning-rounded';
|
||||
case 'neutral':
|
||||
default:
|
||||
return 'info-rounded';
|
||||
}
|
||||
}
|
||||
|
||||
export default function ToastRenderer() {
|
||||
const { toasts, dismiss } = useToast();
|
||||
|
||||
const grouped = toasts.reduce<Record<ToastLocation, ToastInstance[]>>((acc, t) => {
|
||||
const key = t.location;
|
||||
if (!acc[key]) acc[key] = [] as ToastInstance[];
|
||||
acc[key].push(t);
|
||||
return acc;
|
||||
}, { 'top-left': [], 'top-right': [], 'bottom-left': [], 'bottom-right': [] });
|
||||
|
||||
return (
|
||||
<>
|
||||
{(Object.keys(grouped) as ToastLocation[]).map((loc) => (
|
||||
<div key={loc} className={`toast-container ${locationToClass[loc]}`}>
|
||||
{grouped[loc].map(t => {
|
||||
return (
|
||||
<div
|
||||
key={t.id}
|
||||
role="status"
|
||||
className={getToastItemClass(t)}
|
||||
>
|
||||
{/* Top row: Icon + Title + Controls */}
|
||||
<div className="toast-header">
|
||||
{/* Icon */}
|
||||
<div className="toast-icon">
|
||||
{t.icon ?? (
|
||||
<LocalIcon icon={`material-symbols:${getDefaultIconName(t)}`} width={20} height={20} />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Title + count badge */}
|
||||
<div className="toast-title-container">
|
||||
<span>{t.title}</span>
|
||||
{typeof t.count === 'number' && t.count > 1 && (
|
||||
<span className="toast-count-badge">{t.count}</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="toast-controls">
|
||||
{t.expandable && (
|
||||
<button
|
||||
aria-label="Toggle details"
|
||||
onClick={() => {
|
||||
const evt = new CustomEvent('toast:toggle', { detail: { id: t.id } });
|
||||
window.dispatchEvent(evt);
|
||||
}}
|
||||
className={`toast-button toast-expand-button ${t.isExpanded ? 'toast-expand-button--expanded' : ''}`}
|
||||
>
|
||||
<LocalIcon icon="material-symbols:expand-more-rounded" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
aria-label="Dismiss"
|
||||
onClick={() => dismiss(t.id)}
|
||||
className="toast-button"
|
||||
>
|
||||
<LocalIcon icon="material-symbols:close-rounded" width={20} height={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Progress bar - always show when present */}
|
||||
{typeof t.progress === 'number' && (
|
||||
<div className="toast-progress-container">
|
||||
<div
|
||||
className={getProgressBarClass(t)}
|
||||
style={{ width: `${t.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body content - only show when expanded */}
|
||||
{(t.isExpanded || !t.expandable) && (
|
||||
<div className="toast-body">
|
||||
{t.body}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Button - always show when present, positioned below body */}
|
||||
{t.buttonText && t.buttonCallback && (
|
||||
<div className="toast-action-container">
|
||||
<button
|
||||
onClick={t.buttonCallback}
|
||||
className={getActionButtonClass(t)}
|
||||
>
|
||||
{t.buttonText}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { ToastOptions } from '@app/components/toast/types';
|
||||
import { useToast, ToastProvider } from '@app/components/toast/ToastContext';
|
||||
import ToastRenderer from '@app/components/toast/ToastRenderer';
|
||||
|
||||
export { useToast, ToastProvider, ToastRenderer };
|
||||
|
||||
// Global imperative API via module singleton
|
||||
let _api: ReturnType<typeof createImperativeApi> | null = null;
|
||||
|
||||
function createImperativeApi() {
|
||||
const subscribers: Array<(fn: any) => void> = [];
|
||||
let api: any = null;
|
||||
return {
|
||||
provide(instance: any) {
|
||||
api = instance;
|
||||
subscribers.splice(0).forEach(cb => cb(api));
|
||||
},
|
||||
get(): any | null { return api; },
|
||||
onReady(cb: (api: any) => void) {
|
||||
if (api) cb(api); else subscribers.push(cb);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (!_api) _api = createImperativeApi();
|
||||
|
||||
// Hook helper to wire context API back to singleton
|
||||
export function ToastPortalBinder() {
|
||||
const ctx = useToast();
|
||||
// Provide API once mounted
|
||||
_api!.provide(ctx);
|
||||
return null;
|
||||
}
|
||||
|
||||
export function alert(options: ToastOptions) {
|
||||
if (_api?.get()) {
|
||||
return _api.get()!.show(options);
|
||||
}
|
||||
// Queue until provider mounts
|
||||
let id = '';
|
||||
_api?.onReady((api) => { id = api.show(options); });
|
||||
return id;
|
||||
}
|
||||
|
||||
export function updateToast(id: string, options: Partial<ToastOptions>) {
|
||||
_api?.get()?.update(id, options);
|
||||
}
|
||||
|
||||
export function updateToastProgress(id: string, progress: number) {
|
||||
_api?.get()?.updateProgress(id, progress);
|
||||
}
|
||||
|
||||
export function dismissToast(id: string) {
|
||||
_api?.get()?.dismiss(id);
|
||||
}
|
||||
|
||||
export function dismissAllToasts() {
|
||||
_api?.get()?.dismissAll();
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import { ReactNode } from 'react';
|
||||
|
||||
export type ToastLocation = 'top-left' | 'top-right' | 'bottom-left' | 'bottom-right';
|
||||
export type ToastAlertType = 'success' | 'error' | 'warning' | 'neutral';
|
||||
|
||||
export interface ToastOptions {
|
||||
alertType?: ToastAlertType;
|
||||
title: string;
|
||||
body?: ReactNode;
|
||||
buttonText?: string;
|
||||
buttonCallback?: () => void;
|
||||
isPersistentPopup?: boolean;
|
||||
location?: ToastLocation;
|
||||
icon?: ReactNode;
|
||||
/** number 0-1 as fraction or 0-100 as percent */
|
||||
progressBarPercentage?: number;
|
||||
/** milliseconds to auto-close if not persistent */
|
||||
durationMs?: number;
|
||||
/** optional id to control/update later */
|
||||
id?: string;
|
||||
/** If true, show chevron and collapse/expand animation. Defaults to true. */
|
||||
expandable?: boolean;
|
||||
}
|
||||
|
||||
export interface ToastInstance extends Omit<ToastOptions, 'id' | 'progressBarPercentage'> {
|
||||
id: string;
|
||||
alertType: ToastAlertType;
|
||||
isPersistentPopup: boolean;
|
||||
location: ToastLocation;
|
||||
durationMs: number;
|
||||
expandable: boolean;
|
||||
isExpanded: boolean;
|
||||
/** Number of coalesced duplicates */
|
||||
count?: number;
|
||||
/** internal progress normalized 0..100 */
|
||||
progress?: number;
|
||||
/** if progress completed, briefly show check icon */
|
||||
justCompleted: boolean;
|
||||
createdAt: number;
|
||||
}
|
||||
|
||||
export interface ToastApi {
|
||||
show: (options: ToastOptions) => string;
|
||||
update: (id: string, options: Partial<ToastOptions>) => void;
|
||||
updateProgress: (id: string, progress: number) => void;
|
||||
dismiss: (id: string) => void;
|
||||
dismissAll: () => void;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user