111
common/resources/client/ui/toast/toast-container.tsx
Executable file
111
common/resources/client/ui/toast/toast-container.tsx
Executable 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>
|
||||
);
|
||||
}
|
||||
113
common/resources/client/ui/toast/toast-store.ts
Executable file
113
common/resources/client/ui/toast/toast-store.ts
Executable 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();
|
||||
}
|
||||
25
common/resources/client/ui/toast/toast-timer.ts
Executable file
25
common/resources/client/ui/toast/toast-timer.ts
Executable 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);
|
||||
}
|
||||
}
|
||||
21
common/resources/client/ui/toast/toast.ts
Executable file
21
common/resources/client/ui/toast/toast.ts
Executable 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'});
|
||||
};
|
||||
Reference in New Issue
Block a user