feat(ui): add toasts for create edit and run flows

- add global ToastProvider and useToast hook mounted at app root

- show success/error toasts for environment, credential, snippet, and scenario create/edit actions

- show run-started/error toasts on scenario run actions with 100ms exit animation
This commit is contained in:
2026-04-10 21:44:04 +03:00
parent d511c85891
commit 7444082b65
18 changed files with 285 additions and 31 deletions
@@ -0,0 +1,87 @@
.viewport {
position: fixed;
top: var(--space-4);
right: var(--space-4);
z-index: 2000;
display: flex;
flex-direction: column;
gap: var(--space-2);
width: min(380px, calc(100vw - (var(--space-4) * 2)));
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: var(--space-3);
padding: var(--space-3) var(--space-3);
border-radius: var(--radius-md);
border: var(--border-width) solid var(--color-border);
background: var(--color-bg-subtle);
box-shadow: var(--shadow-md);
pointer-events: auto;
opacity: 1;
transform: translateX(0) scale(1);
transition: opacity 100ms ease, transform 100ms ease;
}
.leaving {
opacity: 0;
transform: translateX(8px) scale(0.98);
pointer-events: none;
}
.content {
display: flex;
align-items: flex-start;
gap: var(--space-2);
min-width: 0;
}
.icon {
margin-top: 1px;
flex-shrink: 0;
}
.message {
margin: 0;
font-size: var(--font-size-sm);
line-height: 1.4;
color: var(--color-text);
overflow-wrap: anywhere;
}
.close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border: none;
background: transparent;
border-radius: var(--radius-sm);
color: var(--color-text-muted);
cursor: pointer;
flex-shrink: 0;
}
.close:hover {
background: var(--color-secondary);
color: var(--color-secondary-fg);
}
.success {
border-color: color-mix(in srgb, var(--color-primary) 50%, var(--color-border));
background: var(--color-success-bg);
}
.error {
border-color: color-mix(in srgb, var(--color-danger) 50%, var(--color-border));
background: var(--color-error-bg);
}
.info {
border-color: color-mix(in srgb, var(--color-secondary-fg) 35%, var(--color-border));
background: var(--color-info-bg);
}
+101
View File
@@ -0,0 +1,101 @@
import { createContext, useCallback, useContext, useMemo, useState } from 'react';
import { AlertCircle, CheckCircle2, Info, X } from 'lucide-react';
import styles from './ToastProvider.module.css';
type ToastVariant = 'success' | 'error' | 'info';
interface ToastItem {
id: number;
message: string;
variant: ToastVariant;
closing: boolean;
}
interface ToastApi {
show: (message: string, variant?: ToastVariant) => void;
success: (message: string) => void;
error: (message: string) => void;
info: (message: string) => void;
}
const ToastContext = createContext<ToastApi | null>(null);
const ICONS: Record<ToastVariant, React.ReactNode> = {
success: <CheckCircle2 size={16} />,
error: <AlertCircle size={16} />,
info: <Info size={16} />,
};
export interface ToastProviderProps {
children: React.ReactNode;
durationMs?: number;
}
export function ToastProvider({ children, durationMs = 3500 }: ToastProviderProps) {
const EXIT_ANIMATION_MS = 100;
const [items, setItems] = useState<ToastItem[]>([]);
const remove = useCallback((id: number) => {
setItems((prev) => prev.filter((item) => item.id !== id));
}, []);
const dismiss = useCallback((id: number) => {
setItems((prev) =>
prev.map((item) =>
item.id === id ? { ...item, closing: true } : item,
),
);
window.setTimeout(() => remove(id), EXIT_ANIMATION_MS);
}, [remove]);
const show = useCallback((message: string, variant: ToastVariant = 'info') => {
const id = Date.now() + Math.floor(Math.random() * 1000);
setItems((prev) => [...prev, { id, message, variant, closing: false }]);
window.setTimeout(() => dismiss(id), durationMs);
}, [dismiss, durationMs]);
const api = useMemo<ToastApi>(() => ({
show,
success: (message: string) => show(message, 'success'),
error: (message: string) => show(message, 'error'),
info: (message: string) => show(message, 'info'),
}), [show]);
return (
<ToastContext.Provider value={api}>
{children}
<div className={styles.viewport} aria-live="polite" aria-atomic="true">
{items.map((item) => (
<div
key={item.id}
className={[styles.toast, styles[item.variant], item.closing ? styles.leaving : '']
.filter(Boolean)
.join(' ')}
role="status"
>
<div className={styles.content}>
<span className={styles.icon} aria-hidden="true">{ICONS[item.variant]}</span>
<p className={styles.message}>{item.message}</p>
</div>
<button
type="button"
className={styles.close}
aria-label="Dismiss notification"
onClick={() => dismiss(item.id)}
>
<X size={14} />
</button>
</div>
))}
</div>
</ToastContext.Provider>
);
}
export function useToast(): ToastApi {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error('useToast must be used within ToastProvider');
}
return ctx;
}
+3
View File
@@ -27,6 +27,9 @@ export type { BreadcrumbsProps, BreadcrumbItem } from './Breadcrumbs/Breadcrumbs
export { ThemeSwitcher } from './ThemeSwitcher/ThemeSwitcher';
export { ToastProvider, useToast } from './Toast/ToastProvider';
export type { ToastProviderProps } from './Toast/ToastProvider';
export { Timestamp } from './Timestamp/Timestamp';
export type { TimestampProps } from './Timestamp/Timestamp';