From 48adb2c732dee07f1144ee8f1bb6ec9fe7cdec72 Mon Sep 17 00:00:00 2001
From: nikec <43032218+niikeec@users.noreply.github.com>
Date: Wed, 23 Aug 2023 12:07:16 +0200
Subject: [PATCH] [ENG-556] New toast component (#1235)
* toast
* remove useToasts
* toast improvements
---
interface/app/$libraryId/Layout/Toasts.tsx | 74 ------
interface/app/$libraryId/Layout/index.tsx | 2 -
interface/app/index.tsx | 17 +-
interface/hooks/index.ts | 1 -
interface/hooks/useToasts.ts | 34 ---
packages/ui/package.json | 1 +
packages/ui/src/Toast.tsx | 270 +++++++++++++++++++++
packages/ui/src/index.ts | 1 +
pnpm-lock.yaml | Bin 918945 -> 919379 bytes
9 files changed, 280 insertions(+), 120 deletions(-)
delete mode 100644 interface/app/$libraryId/Layout/Toasts.tsx
delete mode 100644 interface/hooks/useToasts.ts
create mode 100644 packages/ui/src/Toast.tsx
diff --git a/interface/app/$libraryId/Layout/Toasts.tsx b/interface/app/$libraryId/Layout/Toasts.tsx
deleted file mode 100644
index 8b41707b3..000000000
--- a/interface/app/$libraryId/Layout/Toasts.tsx
+++ /dev/null
@@ -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 (
-
-
- <>
- {toasts.map((toast) => (
- 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'
- )}
- >
-
-
-
-
- {toast.title}
-
- {toast.subtitle && (
-
- {toast.subtitle}
-
- )}
-
-
-
-
-
- {toast.actionButton && (
- {
- e.preventDefault();
- toast.actionButton?.onClick();
- removeToast(toast);
- }}
- >
- {toast.actionButton.text || 'Open'}
-
- )}
-
-
-
- Dismiss
-
-
-
-
-
-
- ))}
-
-
- >
-
-
- );
-};
diff --git a/interface/app/$libraryId/Layout/index.tsx b/interface/app/$libraryId/Layout/index.tsx
index eef0411d9..1f13eab58 100644
--- a/interface/app/$libraryId/Layout/index.tsx
+++ b/interface/app/$libraryId/Layout/index.tsx
@@ -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 = () => {
)}
-
);
diff --git a/interface/app/index.tsx b/interface/app/index.tsx
index db5100a1f..1943568d0 100644
--- a/interface/app/index.tsx
+++ b/interface/app/index.tsx
@@ -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 = () => {
+
);
};
@@ -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;
};
diff --git a/interface/hooks/index.ts b/interface/hooks/index.ts
index b5ffe5ac6..5a6b9df84 100644
--- a/interface/hooks/index.ts
+++ b/interface/hooks/index.ts
@@ -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';
diff --git a/interface/hooks/useToasts.ts b/interface/hooks/useToasts.ts
deleted file mode 100644
index f0b624fd6..000000000
--- a/interface/hooks/useToasts.ts
+++ /dev/null
@@ -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) => {
- 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);
- }
- };
-}
diff --git a/packages/ui/package.json b/packages/ui/package.json
index 8428f06f4..57f23e98e 100644
--- a/packages/ui/package.json
+++ b/packages/ui/package.json
@@ -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",
diff --git a/packages/ui/src/Toast.tsx b/packages/ui/src/Toast.tsx
new file mode 100644
index 000000000..acc0e2627
--- /dev/null
+++ b/packages/ui/src/Toast.tsx
@@ -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 = Promise | (() => Promise);
+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 & { onClick?: ToastAction['onClick'] };
+}
+
+interface ToastPromiseOptions {
+ 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 = {
+ 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 (
+
+ );
+ };
+
+ return (
+
+ {(icon || type) && (
+
+ {icon || (type && typeIcon(type))}
+
+ )}
+
+
+ {title &&
{title}}
+
+ {description &&
{description}}
+
+ {(action || cancel) && (
+
+ {action && (
+
+ )}
+
+ {cancel && (
+
+ )}
+
+ )}
+
+
+ {closable && (
+
+ )}
+
+ );
+};
+
+interface PromiseToastProps extends ToastPromiseOptions {
+ id: ToastId;
+ promise: ToastPromise;
+ duration?: number;
+}
+
+const PromiseToast = ({
+ id,
+ promise,
+ loading,
+ success,
+ error,
+ duration
+}: PromiseToastProps) => {
+ const [type, setType] = useState();
+ const [message, setMessage] = useState(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 (
+ }
+ closable={!!type}
+ />
+ );
+};
+
+const renderToast = (
+ message: ToastMessage,
+ { className, type, icon, action, cancel, ...options }: ToastOptions = {}
+) => {
+ return SonnerToast.custom(
+ (id) => (
+
+ ),
+ {
+ className: clsx(toastClassName, className),
+ ...options
+ }
+ );
+};
+
+const renderCustomToast = (
+ jsx: Parameters[0],
+ { className, ...options }: Omit = {}
+) => {
+ return SonnerToast.custom(jsx, {
+ className: clsx(toastClassName, className),
+ ...options
+ });
+};
+
+const renderPromiseToast = (
+ promise: ToastPromise,
+ {
+ className,
+ loading,
+ success,
+ error,
+ duration,
+ ...options
+ }: Omit & ToastPromiseOptions
+) => {
+ return SonnerToast.custom(
+ (id) => (
+
+ ),
+ {
+ className: clsx(toastClassName, className),
+ duration: Infinity,
+ ...options
+ }
+ );
+};
+
+export const toast = Object.assign(renderToast, {
+ info: (message: ToastMessage, options?: Omit) => {
+ return renderToast(message, { ...options, type: 'info' });
+ },
+ success: (message: ToastMessage, options?: Omit) => {
+ return renderToast(message, { ...options, type: 'success' });
+ },
+ error: (message: ToastMessage, options?: Omit) => {
+ return renderToast(message, { ...options, type: 'error' });
+ },
+ custom: renderCustomToast,
+ promise: renderPromiseToast,
+ dismiss: SonnerToast.dismiss
+});
+
+export { Toaster } from 'sonner';
diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts
index 770f2fcd3..fc21b557f 100644
--- a/packages/ui/src/index.ts
+++ b/packages/ui/src/index.ts
@@ -22,3 +22,4 @@ export * from './Divider';
export * from './Shortcut';
export * from './ProgressBar';
export * from './keys';
+export * from './Toast';
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 78d5715d81eebb6982fec9990248dcf63c20d01a..9ef63d241cf20b1f1131d6a827463f918465113e 100644
GIT binary patch
delta 261
zcmZ43ZE?BJVuA>x!A4P=VwU3kyu8UDg{2tHCL0QgF`7+Zc!ODUazP|_b4+o2Oflp3
zm|~`NpQrzJWaDXV79eH?Vm2UV2VxE&<^*CcAm-jKpUzXM$B~zpTI67$2R2P<
z`U620)zG926Swe;$b3IP6AQyA&u~kVaFbMn>|h_K5MTGq;+)8IqsWLdmtez!3PVRP
zuPT$&qQF$kERQgYV$*_P!!X}4m-Ms@W9Q-=zhF0?#85Mzl%l-pACGctOxJkM%`<)9
zUl#u9I+HlKrk{JmA~Jo$Bo2$|2Ocox;YLxL;^qy-?Hh_2w{IwB+WC3