From d249f0d291e06869c5135dbfb62494b7ba57553b Mon Sep 17 00:00:00 2001 From: yzned Date: Fri, 18 Jul 2025 18:29:43 +0300 Subject: [PATCH] create toaster,tooltip,add icons --- package.json | 2 + pnpm-lock.yaml | 6 + src/components/ui/toaster.tsx | 320 ++++++++++++++++++++++++++++++++++ src/components/ui/tooltip.tsx | 59 +++++++ src/icons/check-circle.svg | 2 +- src/icons/check.svg | 2 +- src/icons/chevron.svg | 2 +- src/icons/copy.svg | 2 +- src/icons/info.svg | 2 +- src/icons/warning.svg | 2 +- src/icons/x-mark.svg | 2 +- src/lib/hooks/use-toast.ts | 200 +++++++++++++++++++++ src/main.tsx | 55 +++--- src/routes/index.tsx | 7 +- src/styles.css | 15 ++ 15 files changed, 640 insertions(+), 38 deletions(-) create mode 100644 src/components/ui/toaster.tsx create mode 100644 src/components/ui/tooltip.tsx create mode 100644 src/lib/hooks/use-toast.ts diff --git a/package.json b/package.json index ef29cbd..53674b2 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "test": "vitest run" }, "dependencies": { + "@radix-ui/react-toast": "^1.2.14", + "@radix-ui/react-tooltip": "^1.2.7", "@tailwindcss/vite": "^4.1.11", "@tanstack/react-query": "^5.83.0", "@tanstack/react-router": "^1.121.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 729e823..50700e9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@radix-ui/react-toast': + specifier: ^1.2.14 + version: 1.2.14(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + '@radix-ui/react-tooltip': + specifier: ^1.2.7 + version: 1.2.7(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@tailwindcss/vite': specifier: ^4.1.11 version: 4.1.11(vite@6.3.5(@types/node@24.0.14)(jiti@2.4.2)(lightningcss@1.30.1)(tsx@4.20.3)) diff --git a/src/components/ui/toaster.tsx b/src/components/ui/toaster.tsx new file mode 100644 index 0000000..5cb0183 --- /dev/null +++ b/src/components/ui/toaster.tsx @@ -0,0 +1,320 @@ +import CopyIcon from "@/icons/copy.svg?react"; + +import SuccessIcon from "@/icons/check-circle.svg?react"; +import ErrorIcon from "@/icons/warning.svg?react"; +import InfoIcon from "@/icons/info.svg?react"; + +import * as ToastPrimitives from "@radix-ui/react-toast"; +import { type VariantProps, cva } from "class-variance-authority"; +import * as React from "react"; +import Close from "@/icons/x-mark.svg?react"; + +import { cn } from "@/lib/utils"; +import clsx from "clsx"; +import { useToast } from "@/lib/hooks/use-toast"; + +const ToastProvider = ToastPrimitives.Provider; + +const ToastViewport = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastViewport.displayName = ToastPrimitives.Viewport.displayName; + +const toastVariants = cva( + "group min-h-[88px] min-w-[300px] pointer-events-auto relative flex w-full items-center justify-between overflow-hidden rounded-2xl p-4 pr-4 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full dark:border-neutral-800", + { + variants: { + variant: { + success: "bg-feedback-positive-100", + warning: "bg-feedback-attention-100", + error: "bg-feedback-negative-100", + info: "bg-feedback-info-100", + }, + }, + defaultVariants: { + variant: "success", + }, + }, +); + +const ToastIcon = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant }) => { + if (variant === "success") { + return ( + + ); + } + + if (variant === "warning") { + return ( + + ); + } + + if (variant === "error") { + return ( + + ); + } + + if (variant === "info") { + return ; + } +}); + +const Toast = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef & + VariantProps +>(({ className, variant, ...props }, ref) => { + return ( + + ); +}); +Toast.displayName = ToastPrimitives.Root.displayName; + +const ToastCopy = ({ + text, + href, + toastAction = () => { + if (href) { + navigator.clipboard.writeText(href); + } + }, +}: { + text: string; + href?: string; + toastAction?: () => void; +}) => ( + +); + +const ToastAction = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastAction.displayName = ToastPrimitives.Action.displayName; + +const ToastClose = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + +)); +ToastClose.displayName = ToastPrimitives.Close.displayName; + +const ToastTitle = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); +ToastTitle.displayName = ToastPrimitives.Title.displayName; + +const ToastDescription = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)); + +const ToastTimeline = ({ + duration, + isPaused, + variant, +}: { + duration: number; + isPaused: boolean; + variant?: "error" | "success" | "warning" | "info" | null; +}) => { + return ( +
+
+ +
+
+ ); +}; + +ToastDescription.displayName = ToastPrimitives.Description.displayName; + +type ToastProps = React.ComponentPropsWithoutRef; + +type ToastActionElement = React.ReactElement; + +export function Toaster() { + const { toasts, isPaused, setIsPaused } = useToast(); + + React.useEffect(() => { + const handleFocus = () => { + setIsPaused(false); + }; + + const handleBlur = () => { + setIsPaused(true); + }; + + window.addEventListener("focus", handleFocus); + window.addEventListener("blur", handleBlur); + + return () => { + window.removeEventListener("focus", handleFocus); + window.removeEventListener("blur", handleBlur); + }; + }, [setIsPaused]); + + return ( + + {toasts.map( + ({ + id, + title, + description, + action, + copy, + href, + toastAction, + duration = 5000, + withTimeline, + ...props + }) => ( + setIsPaused(true)} + onMouseLeave={() => setIsPaused(false)} + > + +
+ {title && {title}} + + {description && ( + + {/* {props.variant === "errorWithCopy" && ( +

+ To solve problems, contact us
+ via{" "} + + Discord +

+ {" "} + and send the error text +

+ )} */} + {description} + + )} + + {copy && ( + + )} +
+ {action} + + + {withTimeline && ( + + )} + + ), + )} + + + ); +} + +export { + type ToastProps, + type ToastActionElement, + ToastProvider, + ToastViewport, + Toast, + ToastTitle, + ToastDescription, + ToastClose, + ToastAction, + ToastIcon, + ToastCopy, + ToastTimeline, +}; diff --git a/src/components/ui/tooltip.tsx b/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..66e1ce2 --- /dev/null +++ b/src/components/ui/tooltip.tsx @@ -0,0 +1,59 @@ +import * as React from "react"; +import * as TooltipPrimitive from "@radix-ui/react-tooltip"; + +import { cn } from "@/lib/utils"; + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ); +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }; diff --git a/src/icons/check-circle.svg b/src/icons/check-circle.svg index 8d8ae1c..809e9a2 100644 --- a/src/icons/check-circle.svg +++ b/src/icons/check-circle.svg @@ -1,3 +1,3 @@ - + diff --git a/src/icons/check.svg b/src/icons/check.svg index 0724268..6a9cf72 100644 --- a/src/icons/check.svg +++ b/src/icons/check.svg @@ -1,3 +1,3 @@ - + diff --git a/src/icons/chevron.svg b/src/icons/chevron.svg index 43564a7..b90096f 100644 --- a/src/icons/chevron.svg +++ b/src/icons/chevron.svg @@ -1,3 +1,3 @@ - + diff --git a/src/icons/copy.svg b/src/icons/copy.svg index 68e54cf..18982e8 100644 --- a/src/icons/copy.svg +++ b/src/icons/copy.svg @@ -1,3 +1,3 @@ - + diff --git a/src/icons/info.svg b/src/icons/info.svg index 40d8454..94a8b2f 100644 --- a/src/icons/info.svg +++ b/src/icons/info.svg @@ -1,3 +1,3 @@ - + diff --git a/src/icons/warning.svg b/src/icons/warning.svg index 05d329e..8400d4a 100644 --- a/src/icons/warning.svg +++ b/src/icons/warning.svg @@ -1,3 +1,3 @@ - + diff --git a/src/icons/x-mark.svg b/src/icons/x-mark.svg index 3fa893d..588f4a8 100644 --- a/src/icons/x-mark.svg +++ b/src/icons/x-mark.svg @@ -1,3 +1,3 @@ - + diff --git a/src/lib/hooks/use-toast.ts b/src/lib/hooks/use-toast.ts new file mode 100644 index 0000000..135bf24 --- /dev/null +++ b/src/lib/hooks/use-toast.ts @@ -0,0 +1,200 @@ +import type { ToastActionElement, ToastProps } from "@/components/ui/toaster"; +import { type ReactNode, useEffect, useState } from "react"; + +type ToasterToast = ToastProps & { + id: string; + title?: ReactNode; + description?: ReactNode; + action?: ToastActionElement; + toastAction?: () => void; + copy?: string; + withTimeline?: boolean; + href?: string; +}; + +type ActionType = typeof actionTypes; + +type Action = + | { + type: ActionType["ADD_TOAST"]; + toast: ToasterToast; + } + | { + type: ActionType["UPDATE_TOAST"]; + toast: Partial; + } + | { + type: ActionType["DISMISS_TOAST"]; + toastId?: ToasterToast["id"]; + } + | { + type: ActionType["REMOVE_TOAST"]; + toastId?: ToasterToast["id"]; + }; + +type State = { + toasts: ToasterToast[]; +}; + +const actionTypes = { + ADD_TOAST: "ADD_TOAST", + UPDATE_TOAST: "UPDATE_TOAST", + DISMISS_TOAST: "DISMISS_TOAST", + REMOVE_TOAST: "REMOVE_TOAST", +} as const; + +export type ToastFunction = (props: Toast) => { + id: string; + dismiss: () => void; + update: (props: ToasterToast) => void; +}; + +export type Toast = Omit; + +const TOAST_LIMIT = 1; +const TOAST_REMOVE_DELAY = 1000000; + +let count = 0; + +let memoryState: State = { toasts: [] }; + +const listeners: Array<(state: State) => void> = []; + +const toastTimeouts = new Map>(); + +const genId = () => { + count = (count + 1) % Number.MAX_SAFE_INTEGER; + return count.toString(); +}; + +const addToRemoveQueue = (toastId: string) => { + if (toastTimeouts.has(toastId)) { + return; + } + + const timeout = setTimeout(() => { + toastTimeouts.delete(toastId); + dispatch({ + type: "REMOVE_TOAST", + toastId: toastId, + }); + }, TOAST_REMOVE_DELAY); + + toastTimeouts.set(toastId, timeout); +}; + +export const reducer = (state: State, action: Action): State => { + switch (action.type) { + case "ADD_TOAST": + return { + ...state, + toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT), + }; + + case "UPDATE_TOAST": + return { + ...state, + toasts: state.toasts.map((t) => + t.id === action.toast.id ? { ...t, ...action.toast } : t, + ), + }; + + case "DISMISS_TOAST": { + const { toastId } = action; + + if (toastId) { + addToRemoveQueue(toastId); + } else { + state.toasts.forEach((toast) => { + addToRemoveQueue(toast.id); + }); + } + + return { + ...state, + toasts: state.toasts.map((t) => + t.id === toastId || toastId === undefined + ? { + ...t, + open: false, + } + : t, + ), + }; + } + case "REMOVE_TOAST": + if (action.toastId === undefined) { + return { + ...state, + toasts: [], + }; + } + return { + ...state, + toasts: state.toasts.filter((t) => t.id !== action.toastId), + }; + } +}; + +function dispatch(action: Action) { + memoryState = reducer(memoryState, action); + listeners.forEach((listener) => { + listener(memoryState); + }); +} + +function toast({ ...props }: Toast) { + const id = genId(); + + const update = (props: ToasterToast) => + dispatch({ + type: "UPDATE_TOAST", + toast: { ...props, id }, + }); + const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id }); + + dispatch({ + type: "ADD_TOAST", + toast: { + ...props, + id, + open: true, + onOpenChange: (open) => { + if (!open) dismiss(); + }, + }, + }); + + return { + id: id, + dismiss, + update, + }; +} + +function useToast() { + const [state, setState] = useState(memoryState); + const [isPaused, setIsPaused] = useState(false); + + // biome-ignore lint/correctness/useExhaustiveDependencies: shadcn component + useEffect(() => { + setIsPaused(false); + listeners.push(setState); + return () => { + const index = listeners.indexOf(setState); + if (index > -1) { + listeners.splice(index, 1); + } + }; + }, [state]); + + return { + ...state, + toast, + isPaused, + setIsPaused, + dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }), + }; +} + +export { useToast, toast }; diff --git a/src/main.tsx b/src/main.tsx index 1b09369..10f7546 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,42 +1,47 @@ -import { StrictMode } from 'react' -import ReactDOM from 'react-dom/client' -import { RouterProvider, createRouter } from '@tanstack/react-router' +import { StrictMode } from "react"; +import ReactDOM from "react-dom/client"; +import { RouterProvider, createRouter } from "@tanstack/react-router"; // Import the generated route tree -import { routeTree } from './routeTree.gen' +import { routeTree } from "./routeTree.gen"; -import './styles.css' -import reportWebVitals from './reportWebVitals.ts' +import "./styles.css"; +import reportWebVitals from "./reportWebVitals.ts"; +import { Toaster } from "./components/ui/toaster.tsx"; +import { TooltipProvider } from "./components/ui/tooltip.tsx"; // Create a new router instance const router = createRouter({ - routeTree, - context: {}, - defaultPreload: 'intent', - scrollRestoration: true, - defaultStructuralSharing: true, - defaultPreloadStaleTime: 0, -}) + routeTree, + context: {}, + defaultPreload: "intent", + scrollRestoration: true, + defaultStructuralSharing: true, + defaultPreloadStaleTime: 0, +}); // Register the router instance for type safety -declare module '@tanstack/react-router' { - interface Register { - router: typeof router - } +declare module "@tanstack/react-router" { + interface Register { + router: typeof router; + } } // Render the app -const rootElement = document.getElementById('app') +const rootElement = document.getElementById("app"); if (rootElement && !rootElement.innerHTML) { - const root = ReactDOM.createRoot(rootElement) - root.render( - - - , - ) + const root = ReactDOM.createRoot(rootElement); + root.render( + + + + + + , + ); } // If you want to start measuring performance in your app, pass a function // to log results (for example: reportWebVitals(console.log)) // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals -reportWebVitals() +reportWebVitals(); diff --git a/src/routes/index.tsx b/src/routes/index.tsx index af885e7..b1b72fc 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -1,4 +1,3 @@ -import { TextLink } from "@/components/ui/text-link"; import { createFileRoute } from "@tanstack/react-router"; export const Route = createFileRoute("/")({ @@ -6,9 +5,5 @@ export const Route = createFileRoute("/")({ }); function App() { - return ( -
- Lable -
- ); + return
hi
; } diff --git a/src/styles.css b/src/styles.css index f2d9b56..3a4a559 100644 --- a/src/styles.css +++ b/src/styles.css @@ -135,4 +135,19 @@ font-style: normal; font-display: swap; } + } + + + @keyframes toast-progress { + from { + width: 90%; + } + + to { + width: 0%; + } + } + + .toast-progress-animation { + animation: toast-progress 5s linear forwards; } \ No newline at end of file