[ENG-556] New toast component (#1235)

* toast

* remove useToasts

* toast improvements
This commit is contained in:
nikec
2023-08-23 12:07:16 +02:00
committed by GitHub
parent 6acc47ef09
commit 48adb2c732
9 changed files with 280 additions and 120 deletions

View File

@@ -1,74 +0,0 @@
import * as ToastPrimitive from '@radix-ui/react-toast';
import clsx from 'clsx';
import { useToasts } from '~/hooks/useToasts';
export default () => {
const { toasts, removeToast } = useToasts();
return (
<div className="fixed right-0 flex">
<ToastPrimitive.Provider>
<>
{toasts.map((toast) => (
<ToastPrimitive.Root
key={toast.id}
open={true}
onOpenChange={() => removeToast(toast)}
duration={toast.duration || 3000}
className={clsx(
'm-4 w-80 rounded-lg',
'bg-app-box/20 backdrop-blur',
'radix-state-open:animate-toast-slide-in-bottom md:radix-state-open:animate-toast-slide-in-right',
'radix-state-closed:animate-toast-hide',
'radix-swipe-end:animate-toast-swipe-out',
'translate-x-radix-toast-swipe-move-x',
'radix-swipe-cancel:ease-[ease] radix-swipe-cancel:translate-x-0 radix-swipe-cancel:duration-200',
'border-2 border-white/10 shadow-2xl focus:outline-none focus-visible:ring focus-visible:ring-accent/75'
)}
>
<div className="flex">
<div className="flex w-0 flex-1 items-center py-4 pl-5">
<div className="radix w-full">
<ToastPrimitive.Title className="text-sm font-medium text-black">
{toast.title}
</ToastPrimitive.Title>
{toast.subtitle && (
<ToastPrimitive.Description className="mt-1 text-sm text-black">
{toast.subtitle}
</ToastPrimitive.Description>
)}
</div>
</div>
<div className="flex">
<div className="flex flex-col space-y-1 px-3 py-2">
<div className="flex h-0 flex-1">
{toast.actionButton && (
<ToastPrimitive.Action
altText="view now"
className="flex w-full items-center justify-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-accent hover:bg-white/10 focus:z-10 focus:outline-none focus-visible:ring focus-visible:ring-accent/75"
onClick={(e) => {
e.preventDefault();
toast.actionButton?.onClick();
removeToast(toast);
}}
>
{toast.actionButton.text || 'Open'}
</ToastPrimitive.Action>
)}
</div>
<div className="flex h-0 flex-1">
<ToastPrimitive.Close className="flex w-full items-center justify-center rounded-lg border border-transparent px-3 py-2 text-sm font-medium text-ink-faint hover:bg-white/10 focus:z-10 focus:outline-none focus-visible:ring focus-visible:ring-accent/75">
Dismiss
</ToastPrimitive.Close>
</div>
</div>
</div>
</div>
</ToastPrimitive.Root>
))}
<ToastPrimitive.Viewport />
</>
</ToastPrimitive.Provider>
</div>
);
};

View File

@@ -17,7 +17,6 @@ import { usePlatform } from '~/util/Platform';
import { QuickPreviewContextProvider } from '../Explorer/QuickPreview/Context';
import { LayoutContext } from './Context';
import Sidebar from './Sidebar';
import Toasts from './Toasts';
const Layout = () => {
const { libraries, library } = useClientContext();
@@ -89,7 +88,6 @@ const Layout = () => {
</h1>
)}
</div>
<Toasts />
</div>
</LayoutContext.Provider>
);

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import { Navigate, Outlet, RouteObject, useMatches } from 'react-router-dom';
import { currentLibraryCache, useCachedLibraries, useInvalidateQuery } from '@sd/client';
import { Dialogs } from '@sd/ui';
import { Dialogs, Toaster } from '@sd/ui';
import { RouterErrorBoundary } from '~/ErrorFallback';
import { useKeybindHandler, useTheme } from '~/hooks';
import libraryRoutes from './$libraryId';
@@ -34,6 +34,7 @@ const Wrapper = () => {
<RootContext.Provider value={{ rawPath }}>
<Outlet />
<Dialogs />
<Toaster position="bottom-right" expand={true} />
</RootContext.Provider>
);
};
@@ -75,9 +76,9 @@ const useRawRoutePath = () => {
// we grab the last one as it contains all previous route segments.
const lastMatchId = useMatches().slice(-1)[0]?.id;
const rawPath = useMemo(
() => {
const [rawPath] = lastMatchId
const rawPath = useMemo(() => {
const [rawPath] =
lastMatchId
// Gets a list of the index of each route segment
?.split('-')
?.map((s) => parseInt(s))
@@ -96,12 +97,10 @@ const useRawRoutePath = () => {
return [`${rawPath}/${item.path}`, item];
},
['' as string, { children: routes }] as const
) ?? []
) ?? [];
return rawPath ?? "/"
},
[lastMatchId]
);
return rawPath ?? '/';
}, [lastMatchId]);
return rawPath;
};

View File

@@ -15,7 +15,6 @@ export * from './useScrolled';
export * from './useSearchStore';
export * from './useSpacedropState';
export * from './useTheme';
export * from './useToasts';
export * from './useZodRouteParams';
export * from './useZodSearchParams';
export * from './useIsTextTruncated';

View File

@@ -1,34 +0,0 @@
import { proxy, useSnapshot } from 'valtio';
interface Toast {
id: string;
title: string;
subtitle?: string;
duration?: number;
actionButton?: {
text: string;
onClick: () => void;
};
}
const state = proxy({
toasts: [] as Toast[]
});
const randomId = () => Math.random().toString(36).slice(2);
export function useToasts() {
return {
toasts: useSnapshot(state).toasts,
addToast: (toast: Omit<Toast, 'id'>) => {
state.toasts.push({
id: randomId(),
...toast
});
},
removeToast: (toast: Toast | string) => {
const id = typeof toast === 'string' ? toast : toast.id;
state.toasts = state.toasts.filter((t) => t.id !== id);
}
};
}

View File

@@ -42,6 +42,7 @@
"react-dom": "^18.2.0",
"react-loading-icons": "^1.1.0",
"react-router-dom": "6.9.0",
"sonner": "^0.6.2",
"tailwindcss-radix": "^2.6.0",
"ts-pattern": "^5.0.1",
"use-debounce": "^9.0.4",

270
packages/ui/src/Toast.tsx Normal file
View File

@@ -0,0 +1,270 @@
import clsx from 'clsx';
import { CheckCircle, Icon, Info, WarningCircle, X } from 'phosphor-react';
import { ReactNode, useEffect, useState } from 'react';
import { toast as SonnerToast, ToastT } from 'sonner';
import { Button } from './Button';
import { Loader } from './Loader';
type ToastId = ToastT['id'];
type ToastType = 'info' | 'success' | 'error';
type ToastMessage = ReactNode | { title: string; description?: string };
type ToastPromiseData = unknown;
type ToastPromise<T = ToastPromiseData> = Promise<T> | (() => Promise<T>);
type ToastAction = { label: string; onClick: () => void; className?: string };
interface ToastOptions
extends Omit<
ToastT,
| 'id'
| 'type'
| 'action'
| 'cancel'
| 'delete'
| 'promise'
| 'jsx'
| 'title'
| 'description'
| 'descriptionClassName'
> {
id?: ToastId;
type?: ToastType;
action?: ToastAction;
cancel?: Omit<ToastAction, 'onClick'> & { onClick?: ToastAction['onClick'] };
}
interface ToastPromiseOptions<T = ToastPromiseData> {
loading: ReactNode;
success: ReactNode | ((data: T) => ReactNode);
error: ReactNode | ((error: unknown) => ReactNode);
}
const toastClassName =
'w-full cursor-default select-none overflow-hidden rounded-md border border-app-line bg-app-darkBox/90 shadow-lg p-3 text-sm text-ink-faint backdrop-blur';
const actionButtonClassName = '!rounded !px-1.5 !py-0.5 !font-normal';
interface ToastProps {
id: ToastId;
type?: ToastType;
message: ToastMessage;
icon?: ReactNode;
action?: ToastAction;
cancel?: ToastOptions['cancel'];
closable?: boolean;
}
const icons: Record<ToastType, Icon> = {
success: CheckCircle,
error: WarningCircle,
info: Info
};
const Toast = ({ id, type, message, icon, action, cancel, closable = true }: ToastProps) => {
const title =
message && typeof message === 'object' && 'title' in message ? message.title : message;
const description =
message && typeof message === 'object' && 'description' in message
? message.description
: undefined;
const typeIcon = (type: ToastType) => {
const Icon = icons[type];
return (
<Icon
size={16}
weight="fill"
className={clsx(
type === 'success' && 'text-green-500',
type === 'error' && 'text-red-500'
)}
/>
);
};
return (
<div
className={clsx(
'flex gap-2',
description || action || cancel ? 'items-start' : 'items-center'
)}
>
{(icon || type) && (
<div className={clsx((description || action || cancel) && 'mt-px')}>
{icon || (type && typeIcon(type))}
</div>
)}
<div className="flex grow flex-col">
{title && <span className="font-medium text-ink">{title}</span>}
{description && <span className="mt-0.5">{description}</span>}
{(action || cancel) && (
<div className="mt-2.5 flex gap-2">
{action && (
<Button
variant="accent"
onClick={() => {
action.onClick();
SonnerToast.dismiss(id);
}}
className={clsx(actionButtonClassName, action.className)}
>
{action.label}
</Button>
)}
{cancel && (
<Button
variant="gray"
onClick={() => {
cancel.onClick?.();
SonnerToast.dismiss(id);
}}
className={clsx(actionButtonClassName, cancel.className)}
>
{cancel.label}
</Button>
)}
</div>
)}
</div>
{closable && (
<button
className="relative transition-colors before:absolute before:-inset-2 before:content-[''] hover:text-ink"
onClick={() => toast.dismiss(id)}
>
<X weight="bold" />
</button>
)}
</div>
);
};
interface PromiseToastProps<T = ToastPromiseData> extends ToastPromiseOptions<T> {
id: ToastId;
promise: ToastPromise<T>;
duration?: number;
}
const PromiseToast = <T extends ToastPromiseData>({
id,
promise,
loading,
success,
error,
duration
}: PromiseToastProps<T>) => {
const [type, setType] = useState<ToastType>();
const [message, setMessage] = useState<ToastMessage>(loading);
useEffect(() => {
const resolve = async () => {
try {
const res = await (promise instanceof Promise ? promise : promise());
const message = typeof success === 'function' ? success(res) : success;
setMessage(message);
setType('success');
} catch (err) {
const message = typeof error === 'function' ? error(err) : error;
setMessage(message);
setType('error');
}
setTimeout(() => toast.dismiss(id), duration || 4000);
};
resolve();
}, [id, promise, success, error, duration]);
return (
<Toast
id={id}
type={type}
message={message}
icon={!type && <Loader className="!h-4 !w-4" />}
closable={!!type}
/>
);
};
const renderToast = (
message: ToastMessage,
{ className, type, icon, action, cancel, ...options }: ToastOptions = {}
) => {
return SonnerToast.custom(
(id) => (
<Toast
id={id}
message={message}
type={type}
icon={icon}
action={action}
cancel={cancel}
/>
),
{
className: clsx(toastClassName, className),
...options
}
);
};
const renderCustomToast = (
jsx: Parameters<typeof SonnerToast.custom>[0],
{ className, ...options }: Omit<ToastOptions, 'icon' | 'type' | 'action' | 'cancel'> = {}
) => {
return SonnerToast.custom(jsx, {
className: clsx(toastClassName, className),
...options
});
};
const renderPromiseToast = <T extends ToastPromiseData>(
promise: ToastPromise<T>,
{
className,
loading,
success,
error,
duration,
...options
}: Omit<ToastOptions, 'icon' | 'type' | 'action' | 'cancel'> & ToastPromiseOptions<T>
) => {
return SonnerToast.custom(
(id) => (
<PromiseToast
id={id}
promise={promise}
loading={loading}
success={success}
error={error}
duration={duration}
/>
),
{
className: clsx(toastClassName, className),
duration: Infinity,
...options
}
);
};
export const toast = Object.assign(renderToast, {
info: (message: ToastMessage, options?: Omit<ToastOptions, 'type'>) => {
return renderToast(message, { ...options, type: 'info' });
},
success: (message: ToastMessage, options?: Omit<ToastOptions, 'type'>) => {
return renderToast(message, { ...options, type: 'success' });
},
error: (message: ToastMessage, options?: Omit<ToastOptions, 'type'>) => {
return renderToast(message, { ...options, type: 'error' });
},
custom: renderCustomToast,
promise: renderPromiseToast,
dismiss: SonnerToast.dismiss
});
export { Toaster } from 'sonner';

View File

@@ -22,3 +22,4 @@ export * from './Divider';
export * from './Shortcut';
export * from './ProgressBar';
export * from './keys';
export * from './Toast';

BIN
pnpm-lock.yaml generated
View File

Binary file not shown.