improvement: updated toasts. no invisible toast anymore.

This commit is contained in:
2026-07-12 21:30:28 +03:30
parent 540a339723
commit 53b43f2cdb
14 changed files with 236 additions and 140 deletions

View File

@@ -40,7 +40,10 @@ function hasToastMessages(messages: ToastMessages): boolean {
return Boolean(messages.error || messages.success || messages.info || messages.default);
}
/** Renders active toast messages with shared badge colors (success / warning / danger / default). */
/**
* Inline toast stack — prefer global `ToastProvider` + `useToast()` for page feedback.
* Dialog/form field errors should stay inline near the control.
*/
export function ToastStack({ error, success, info, default: defaultMessage, className = '' }: ToastStackProps) {
if (!hasToastMessages({ error, success, info, default: defaultMessage })) {
return null;
@@ -63,8 +66,9 @@ export type ToastViewportProps = ToastStackProps & {
};
const viewportPositionClass: Record<Exclude<ToastViewportPosition, 'inline'>, string> = {
top: 'fixed top-4 left-0 right-0 z-[70] px-4 pointer-events-none',
bottom: 'fixed bottom-4 left-0 right-0 z-[70] px-4 pointer-events-none',
top: 'fixed top-4 left-0 right-0 z-[100] px-4 pointer-events-none',
bottom:
'fixed bottom-4 left-0 right-0 z-[100] px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pointer-events-none',
};
/**
@@ -87,7 +91,7 @@ export function ToastViewport({
return (
<div className={viewportPositionClass[position]}>
<div className="pointer-events-auto w-full">{stack}</div>
<div className="pointer-events-auto mx-auto w-full max-w-md">{stack}</div>
</div>
);
}

View File

@@ -0,0 +1,213 @@
'use client';
import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';
import {
ToastStack,
type ToastMessages,
type ToastViewportPosition,
} from '@/components/ui/shared/Toast';
const DEFAULT_DURATION_MS = 4000;
export type UseToastOptions = {
successMs?: number;
errorMs?: number;
infoMs?: number;
defaultMs?: number;
};
export type ToastContextValue = {
error: string;
success: string;
info: string;
defaultMessage: string;
setError: (message: string) => void;
setSuccess: (message: string) => void;
setInfo: (message: string) => void;
setDefaultMessage: (message: string) => void;
showError: (message: string) => void;
showSuccess: (message: string) => void;
showInfo: (message: string) => void;
showDefault: (message: string) => void;
clear: () => void;
messages: ToastMessages;
};
const ToastContext = createContext<ToastContextValue | null>(null);
function useToastState(options: UseToastOptions = {}): ToastContextValue {
const successMs = options.successMs ?? DEFAULT_DURATION_MS;
const errorMs = options.errorMs ?? DEFAULT_DURATION_MS;
const infoMs = options.infoMs ?? DEFAULT_DURATION_MS;
const defaultMs = options.defaultMs ?? DEFAULT_DURATION_MS;
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [info, setInfo] = useState('');
const [defaultMessage, setDefaultMessage] = useState('');
useEffect(() => {
if (!success) return;
const id = setTimeout(() => setSuccess(''), successMs);
return () => clearTimeout(id);
}, [success, successMs]);
useEffect(() => {
if (!error) return;
const id = setTimeout(() => setError(''), errorMs);
return () => clearTimeout(id);
}, [error, errorMs]);
useEffect(() => {
if (!info) return;
const id = setTimeout(() => setInfo(''), infoMs);
return () => clearTimeout(id);
}, [info, infoMs]);
useEffect(() => {
if (!defaultMessage) return;
const id = setTimeout(() => setDefaultMessage(''), defaultMs);
return () => clearTimeout(id);
}, [defaultMessage, defaultMs]);
const clear = useCallback(() => {
setError('');
setSuccess('');
setInfo('');
setDefaultMessage('');
}, []);
const showError = useCallback((message: string) => {
setSuccess('');
setInfo('');
setDefaultMessage('');
setError(message);
}, []);
const showSuccess = useCallback((message: string) => {
setError('');
setInfo('');
setDefaultMessage('');
setSuccess(message);
}, []);
const showInfo = useCallback((message: string) => {
setError('');
setSuccess('');
setDefaultMessage('');
setInfo(message);
}, []);
const showDefault = useCallback((message: string) => {
setError('');
setSuccess('');
setInfo('');
setDefaultMessage(message);
}, []);
const messages: ToastMessages = useMemo(
() => ({ error, success, info, default: defaultMessage }),
[error, success, info, defaultMessage],
);
return useMemo(
() => ({
error,
success,
info,
defaultMessage,
setError,
setSuccess,
setInfo,
setDefaultMessage,
showError,
showSuccess,
showInfo,
showDefault,
clear,
messages,
}),
[
error,
success,
info,
defaultMessage,
showError,
showSuccess,
showInfo,
showDefault,
clear,
messages,
],
);
}
type ToastProviderProps = {
children: ReactNode;
/** Fixed viewport position. Default `bottom` — visible when scrolled and above dialogs. */
position?: Exclude<ToastViewportPosition, 'inline'>;
};
function GlobalToastHost({
messages,
position,
}: {
messages: ToastMessages;
position: Exclude<ToastViewportPosition, 'inline'>;
}) {
const hasMessage = Boolean(
messages.error || messages.success || messages.info || messages.default,
);
if (!hasMessage) {
return null;
}
const positionClass =
position === 'bottom'
? 'fixed bottom-4 left-0 right-0 z-[100] px-4 pb-[max(1rem,env(safe-area-inset-bottom))] pointer-events-none'
: 'fixed top-4 left-0 right-0 z-[100] px-4 pt-[max(1rem,env(safe-area-inset-top))] pointer-events-none';
return createPortal(
<div className={positionClass} aria-live="polite">
<div className="pointer-events-auto mx-auto w-full max-w-md">
<ToastStack {...messages} />
</div>
</div>,
document.body,
);
}
export function ToastProvider({ children, position = 'bottom' }: ToastProviderProps) {
const toast = useToastState();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
return (
<ToastContext.Provider value={toast}>
{children}
{mounted ? <GlobalToastHost messages={toast.messages} position={position} /> : null}
</ToastContext.Provider>
);
}
/** Global app toasts — requires `<ToastProvider>` ancestor (dashboard layout). */
export function useToast(): ToastContextValue {
const context = useContext(ToastContext);
if (!context) {
throw new Error('useToast must be used within ToastProvider');
}
return context;
}