first commit
Some checks failed
Build / run (push) Has been cancelled

This commit is contained in:
maher
2025-10-29 11:42:25 +01:00
commit 703f50a09d
4595 changed files with 385164 additions and 0 deletions

View File

@@ -0,0 +1,111 @@
import {AnimatePresence, m, Target, TargetAndTransition} from 'framer-motion';
import React from 'react';
import clsx from 'clsx';
import {IconButton} from '../buttons/icon-button';
import {CloseIcon} from '../../icons/material/Close';
import {MixedText} from '../../i18n/mixed-text';
import {Button} from '../buttons/button';
import {toastState, useToastStore} from './toast-store';
import {Link} from 'react-router-dom';
import {ErrorOutlineIcon} from '../../icons/material/ErrorOutline';
import {CheckCircleIcon} from '../../icons/material/CheckCircle';
import {ProgressCircle} from '@common/ui/progress/progress-circle';
const initial: Target = {opacity: 0, y: 50, scale: 0.3};
const animate: TargetAndTransition = {opacity: 1, y: 0, scale: 1};
const exit: TargetAndTransition = {
opacity: 0,
scale: 0.5,
};
export function ToastContainer() {
const toasts = useToastStore(s => s.toasts);
return (
<div className="relative pointer-events-none">
<AnimatePresence initial={false}>
{toasts.map(toast => (
<div
key={toast.id}
className={clsx(
'fixed mx-auto p-20 z-toast',
toast.position === 'bottom-center'
? 'left-0 right-0 bottom-0'
: 'right-0 bottom-0'
)}
>
<m.div
initial={toast.disableEnterAnimation ? undefined : initial}
animate={toast.disableEnterAnimation ? undefined : animate}
exit={toast.disableExitAnimation ? undefined : exit}
className={clsx(
'flex items-center gap-10 min-w-288 max-w-500 shadow-lg w-min rounded-lg pl-16 pr-6 py-6 text-sm pointer-events-auto max-h-100 bg-paper text-main bg-paper border mx-auto min-h-50'
)}
onPointerEnter={() => toast.timer?.pause()}
onPointerLeave={() => toast.timer?.resume()}
role="alert"
aria-live={toast.type === 'danger' ? 'assertive' : 'polite'}
>
{toast.type === 'danger' && (
<ErrorOutlineIcon
className="text-danger flex-shrink-0"
size="md"
/>
)}
{toast.type === 'loading' && (
<ProgressCircle
size="sm"
className="flex-shrink-0"
isIndeterminate
/>
)}
{toast.type === 'positive' && (
<CheckCircleIcon
className="text-positive flex-shrink-0"
size="md"
/>
)}
<div
className="overflow-hidden overflow-ellipsis w-max mr-auto"
data-testid="toast-message"
>
<MixedText value={toast.message} />
</div>
{toast.action && (
<Button
variant="text"
color="primary"
size="sm"
className="flex-shrink-0"
onFocus={() => toast.timer?.pause()}
onBlur={() => toast.timer?.resume()}
onClick={() => toastState().remove(toast.id)}
elementType={Link}
to={toast.action.action}
>
<MixedText value={toast.action.label} />
</Button>
)}
{toast.type !== 'loading' && (
<IconButton
onFocus={() => toast.timer?.pause()}
onBlur={() => toast.timer?.resume()}
type="button"
className="flex-shrink-0"
onClick={() => {
toastState().remove(toast.id);
}}
size="sm"
>
<CloseIcon />
</IconButton>
)}
</m.div>
</div>
))}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,113 @@
import {create} from 'zustand';
import {immer} from 'zustand/middleware/immer';
import {MessageDescriptor} from '../../i18n/message-descriptor';
import {nanoid} from 'nanoid';
import {ToastTimer} from './toast-timer';
type ToastType = 'danger' | 'default' | 'positive' | 'loading' | null;
type ToastPosition = 'bottom-center' | 'bottom-right';
interface ToastAction {
label: string | MessageDescriptor;
action: string;
}
export interface ToastOptions {
type?: ToastType;
action?: ToastAction;
id?: string | number;
duration?: number;
position?: 'bottom-center' | 'bottom-right';
disableExitAnimation?: boolean;
disableEnterAnimation?: boolean;
}
interface Toast {
timer?: ToastTimer | null;
message: string | MessageDescriptor;
type: ToastType;
id: string | number;
duration: number;
action?: ToastAction;
position: ToastPosition;
disableExitAnimation?: boolean;
disableEnterAnimation?: boolean;
}
interface ToastStore {
toasts: Toast[];
add: (message: Toast['message'], opts?: ToastOptions) => void;
remove: (toastId: string | number) => void;
}
const maximumVisible = 1;
function getDefaultDuration(type: ToastType) {
switch (type) {
case 'danger':
return 8000;
case 'loading':
return 0;
default:
return 3000;
}
}
export const useToastStore = create<ToastStore>()(
immer((set, get) => ({
toasts: [],
add: (message, opts) => {
const amountToRemove = get().toasts.length + 1 - maximumVisible;
if (amountToRemove > 0) {
set(state => {
state.toasts.splice(0, amountToRemove);
});
}
const toastId = opts?.id || nanoid(6);
const toastType = opts?.type || 'positive';
const duration = opts?.duration ?? getDefaultDuration(toastType);
const toast: Toast = {
timer:
duration > 0
? new ToastTimer(() => get().remove(toastId), duration)
: null,
message,
...opts,
id: toastId,
type: toastType,
position: opts?.position || 'bottom-center',
duration,
disableExitAnimation: opts?.disableExitAnimation,
disableEnterAnimation: opts?.disableEnterAnimation,
};
const toastIndex = get().toasts.findIndex(t => t.id === toast.id);
if (toastIndex > -1) {
set(state => {
state.toasts[toastIndex] = toast;
});
} else {
set(state => {
state.toasts.push(toast);
});
}
},
remove: toastId => {
const newToasts = get().toasts.filter(toast => {
if (toastId === toast.id) {
toast.timer?.clear();
return false;
}
return true;
});
set(state => {
state.toasts = newToasts;
});
},
}))
);
export function toastState() {
return useToastStore.getState();
}

View File

@@ -0,0 +1,25 @@
export class ToastTimer {
private timerId?: ReturnType<typeof setTimeout>;
private createdAt: number = 0;
constructor(private callback: () => void, private remaining: number) {
this.resume();
}
pause() {
clearTimeout(this.timerId);
this.remaining -= Date.now() - this.createdAt;
}
resume() {
this.createdAt = Date.now();
if (this.timerId) {
clearTimeout(this.timerId);
}
this.timerId = setTimeout(this.callback, this.remaining);
}
clear() {
clearTimeout(this.timerId);
}
}

View File

@@ -0,0 +1,21 @@
import {MessageDescriptor} from '../../i18n/message-descriptor';
import {ToastOptions, toastState} from './toast-store';
export function toast(
message: MessageDescriptor | string,
opts?: ToastOptions
) {
toastState().add(message, opts);
}
toast.danger = (message: MessageDescriptor | string, opts?: ToastOptions) => {
toastState().add(message, {...opts, type: 'danger'});
};
toast.positive = (message: MessageDescriptor | string, opts?: ToastOptions) => {
toastState().add(message, {...opts, type: 'positive'});
};
toast.loading = (message: MessageDescriptor | string, opts?: ToastOptions) => {
toastState().add(message, {...opts, type: 'loading'});
};