mirror of
https://github.com/spacedriveapp/spacedrive.git
synced 2026-02-20 07:37:26 -05:00
[ENG-556] New toast component (#1235)
* toast * remove useToasts * toast improvements
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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
270
packages/ui/src/Toast.tsx
Normal 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';
|
||||
@@ -22,3 +22,4 @@ export * from './Divider';
|
||||
export * from './Shortcut';
|
||||
export * from './ProgressBar';
|
||||
export * from './keys';
|
||||
export * from './Toast';
|
||||
|
||||
BIN
pnpm-lock.yaml
generated
BIN
pnpm-lock.yaml
generated
Binary file not shown.
Reference in New Issue
Block a user