feat(ui, dashboard): Toast rework (#7076)

**What**
- Re-works how toasts work in Medusa UI. API is now built on top of `sonner` instead of `@radix-ui/react-toast`. This is a breaking change, and we will need to update the documentation once this has been merged and released (cc: @shahednasser).
- Adds an example of usage in the products list table in the new admin dashboard. As part of the coming weeks cleanup we will add toasts everywhere that they are currently missing.

CLOSES CORE-1977
This commit is contained in:
Kasper Fabricius Kristensen
2024-04-16 15:14:58 +02:00
committed by GitHub
parent 379ff7a36d
commit c3260a2c5a
15 changed files with 730 additions and 516 deletions

View File

@@ -0,0 +1,13 @@
---
"@medusajs/ui": major
---
feat(ui): Re-work `<Toaster />` and `<Toast />` based on `sonner`.
This update contains breaking changes to how toasts work in `@medusajs/ui`. This update has been made to provide a better user experience and to make it easier to use toasts in your Medusa application.
### BREAKING CHANGES
The `useToast` hook has been removed. Users should instead use the `toast` function that is exported from the `@medusajs/ui` package. This function can be used to show toasts in your application. For more information on how to use the `toast` function, please refer to the documentation.
The `Toaster` component is still available but the options for the component have changed. The default position has been changed to `bottom-right`. For more information on the `Toaster` component, please refer to the documentation.

View File

@@ -66,6 +66,7 @@
"cancel": "Cancel", "cancel": "Cancel",
"complete": "Complete", "complete": "Complete",
"back": "Back", "back": "Back",
"close": "Close",
"continue": "Continue", "continue": "Continue",
"confirm": "Confirm", "confirm": "Confirm",
"edit": "Edit", "edit": "Edit",
@@ -283,6 +284,17 @@
"create": { "create": {
"header": "Create Option" "header": "Create Option"
} }
},
"toasts": {
"delete": {
"success": {
"header": "Product deleted",
"description": "{{title}} was successfully deleted."
},
"error": {
"header": "Failed to delete product"
}
}
} }
}, },
"collections": { "collections": {

View File

@@ -1,6 +1,7 @@
import { PencilSquare, Trash } from "@medusajs/icons" import { PencilSquare, Trash } from "@medusajs/icons"
import type { Product } from "@medusajs/medusa" import type { Product } from "@medusajs/medusa"
import { Button, Container, Heading, usePrompt } from "@medusajs/ui" import { Button, Container, Heading, toast, usePrompt } from "@medusajs/ui"
import { keepPreviousData } from "@tanstack/react-query"
import { createColumnHelper } from "@tanstack/react-table" import { createColumnHelper } from "@tanstack/react-table"
import { useMemo } from "react" import { useMemo } from "react"
import { useTranslation } from "react-i18next" import { useTranslation } from "react-i18next"
@@ -8,15 +9,15 @@ import { Link, Outlet, useLoaderData } from "react-router-dom"
import { ActionMenu } from "../../../../../components/common/action-menu" import { ActionMenu } from "../../../../../components/common/action-menu"
import { DataTable } from "../../../../../components/table/data-table" import { DataTable } from "../../../../../components/table/data-table"
import {
useDeleteProduct,
useProducts,
} from "../../../../../hooks/api/products"
import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns" import { useProductTableColumns } from "../../../../../hooks/table/columns/use-product-table-columns"
import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters" import { useProductTableFilters } from "../../../../../hooks/table/filters/use-product-table-filters"
import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query" import { useProductTableQuery } from "../../../../../hooks/table/query/use-product-table-query"
import { useDataTable } from "../../../../../hooks/use-data-table" import { useDataTable } from "../../../../../hooks/use-data-table"
import { productsLoader } from "../../loader" import { productsLoader } from "../../loader"
import {
useDeleteProduct,
useProducts,
} from "../../../../../hooks/api/products"
const PAGE_SIZE = 20 const PAGE_SIZE = 20
@@ -34,7 +35,7 @@ export const ProductListTable = () => {
}, },
{ {
initialData, initialData,
keepPreviousData: true, placeholderData: keepPreviousData,
} }
) )
@@ -99,7 +100,22 @@ const ProductActions = ({ product }: { product: Product }) => {
return return
} }
await mutateAsync() await mutateAsync(undefined, {
onSuccess: () => {
toast.success(t("products.toasts.delete.success.header"), {
description: t("products.toasts.delete.success.description", {
title: product.title,
}),
dismissLabel: t("actions.close"),
})
},
onError: (e) => {
toast.error(t("products.toasts.delete.error.header"), {
description: e.message,
dismissLabel: t("actions.close"),
})
},
})
} }
return ( return (

View File

@@ -60,19 +60,19 @@
"@types/react-dom": "^18.2.0", "@types/react-dom": "^18.2.0",
"@vitejs/plugin-react": "^4.0.1", "@vitejs/plugin-react": "^4.0.1",
"@vitest/coverage-v8": "^0.32.2", "@vitest/coverage-v8": "^0.32.2",
"autoprefixer": "^10.4.17", "autoprefixer": "^10.4.19",
"chromatic": "^6.20.0", "chromatic": "^6.20.0",
"eslint": "^7.32.0", "eslint": "^7.32.0",
"eslint-plugin-storybook": "^0.6.12", "eslint-plugin-storybook": "^0.6.12",
"jsdom": "^22.1.0", "jsdom": "^22.1.0",
"postcss": "^8.4.33", "postcss": "^8.4.38",
"prop-types": "^15.8.1", "prop-types": "^15.8.1",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"resize-observer-polyfill": "^1.5.1", "resize-observer-polyfill": "^1.5.1",
"rimraf": "^5.0.1", "rimraf": "^5.0.1",
"storybook": "^7.0.23", "storybook": "^7.0.23",
"tailwindcss": "^3.4.1", "tailwindcss": "^3.4.3",
"tsc-alias": "^1.8.7", "tsc-alias": "^1.8.7",
"typescript": "^5.1.6", "typescript": "^5.1.6",
"vite": "^4.3.9", "vite": "^4.3.9",
@@ -96,7 +96,6 @@
"@radix-ui/react-slot": "^1.0.2", "@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-switch": "^1.0.3", "@radix-ui/react-switch": "^1.0.3",
"@radix-ui/react-tabs": "^1.0.4", "@radix-ui/react-tabs": "^1.0.4",
"@radix-ui/react-toast": "^1.1.4",
"@radix-ui/react-tooltip": "^1.0.6", "@radix-ui/react-tooltip": "^1.0.6",
"@react-aria/datepicker": "^3.5.0", "@react-aria/datepicker": "^3.5.0",
"@react-stately/datepicker": "^3.5.0", "@react-stately/datepicker": "^3.5.0",
@@ -108,6 +107,7 @@
"prismjs": "^1.29.0", "prismjs": "^1.29.0",
"react-currency-input-field": "^3.6.11", "react-currency-input-field": "^3.6.11",
"react-day-picker": "^8.8.0", "react-day-picker": "^8.8.0",
"sonner": "^1.4.41",
"tailwind-merge": "^2.2.1" "tailwind-merge": "^2.2.1"
}, },
"peerDependencies": { "peerDependencies": {

View File

@@ -1,7 +1,7 @@
import type { Meta, StoryObj } from "@storybook/react" import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react" import * as React from "react"
import { Toast, ToastProvider, ToastViewport } from "./toast" import { Toast } from "./toast"
const meta: Meta<typeof Toast> = { const meta: Meta<typeof Toast> = {
title: "Components/Toast", title: "Components/Toast",
@@ -10,13 +10,7 @@ const meta: Meta<typeof Toast> = {
layout: "centered", layout: "centered",
}, },
render: (args) => { render: (args) => {
return ( return <Toast {...args} />
<ToastProvider>
<ToastViewport>
<Toast {...args} />
</ToastViewport>
</ToastProvider>
)
}, },
} }
@@ -28,7 +22,7 @@ export const Information: Story = {
args: { args: {
title: "Label", title: "Label",
description: "The quick brown fox jumps over a lazy dog.", description: "The quick brown fox jumps over a lazy dog.",
open: true, variant: "info",
}, },
} }
@@ -37,7 +31,6 @@ export const Warning: Story = {
title: "Label", title: "Label",
description: "The quick brown fox jumps over a lazy dog.", description: "The quick brown fox jumps over a lazy dog.",
variant: "warning", variant: "warning",
open: true,
}, },
} }
@@ -46,7 +39,6 @@ export const Error: Story = {
title: "Label", title: "Label",
description: "The quick brown fox jumps over a lazy dog.", description: "The quick brown fox jumps over a lazy dog.",
variant: "error", variant: "error",
open: true,
}, },
} }
@@ -55,7 +47,6 @@ export const Success: Story = {
title: "Label", title: "Label",
description: "The quick brown fox jumps over a lazy dog.", description: "The quick brown fox jumps over a lazy dog.",
variant: "success", variant: "success",
open: true,
}, },
} }
@@ -64,7 +55,6 @@ export const Loading: Story = {
title: "Label", title: "Label",
description: "The quick brown fox jumps over a lazy dog.", description: "The quick brown fox jumps over a lazy dog.",
variant: "loading", variant: "loading",
open: true,
}, },
} }
@@ -73,7 +63,6 @@ export const WithAction: Story = {
title: "Scheduled meeting", title: "Scheduled meeting",
description: "The meeting has been added to your calendar.", description: "The meeting has been added to your calendar.",
variant: "success", variant: "success",
open: true,
action: { action: {
altText: "Undo adding meeting to calendar", altText: "Undo adding meeting to calendar",
onClick: () => {}, onClick: () => {},

View File

@@ -1,189 +1,110 @@
"use client" import { CheckCircleSolid, ExclamationCircleSolid, InformationCircleSolid, Spinner, XCircleSolid } from "@medusajs/icons"
import {
CheckCircleSolid,
ExclamationCircleSolid,
InformationCircleSolid,
Spinner,
XCircleSolid,
} from "@medusajs/icons"
import * as Primitives from "@radix-ui/react-toast"
import * as React from "react" import * as React from "react"
import type { ToastAction, ToastVariant } from "@/types"
import { clx } from "@/utils/clx" import { clx } from "@/utils/clx"
const ToastProvider = Primitives.Provider interface ToastComponentProps {
ToastProvider.displayName = "ToastProvider" id: string | number
variant?: ToastVariant
const ToastViewport = React.forwardRef< title: string
React.ElementRef<typeof Primitives.Viewport>,
React.ComponentPropsWithoutRef<typeof Primitives.Viewport>
>(({ className, ...props }, ref) => (
<Primitives.Viewport
ref={ref}
className={clx(
"fixed right-0 top-0 z-[9999] w-full p-6 md:max-w-[484px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = "Toast.Viewport"
interface ActionProps {
label: string
altText: string
onClick: () => void | Promise<void>
}
interface ToastProps
extends React.ComponentPropsWithoutRef<typeof Primitives.Root> {
variant?: "info" | "success" | "warning" | "error" | "loading"
title?: string
description?: string description?: string
action?: ActionProps action?: ToastAction
disableDismiss?: boolean onDismiss?: (id?: string | number) => void
dismissLabel?: string
} }
/** export const Toast = ({
* This component is based on the [Radix UI Toast](https://www.radix-ui.com/primitives/docs/components/toast) primitives. id,
* variant = "info",
* @excludeExternal title,
*/ description,
const Toast = React.forwardRef< action,
React.ElementRef<typeof Primitives.Root>, onDismiss,
ToastProps dismissLabel = "Close",
>( }: ToastComponentProps) => {
( const hasActionables = !!action || onDismiss
{ let icon = undefined
className,
/**
* The toast's style.
*/
variant,
/**
* The toast's title.
*/
title,
/**
* The toast's content.
*/
description,
/**
* The actions to show in the toast as buttons.
*/
action,
/**
* Whether to hide the Close button.
*/
disableDismiss = false,
...props
}: ToastProps,
ref
) => {
let Icon = undefined
switch (variant) { switch (variant) {
case "success": case "success":
Icon = <CheckCircleSolid className="text-ui-tag-green-icon" /> icon = <CheckCircleSolid className="text-ui-tag-green-icon" />
break break
case "warning": case "warning":
Icon = <ExclamationCircleSolid className="text-ui-tag-orange-icon" /> icon = <ExclamationCircleSolid className="text-ui-tag-orange-icon" />
break break
case "error": case "error":
Icon = <XCircleSolid className="text-ui-tag-red-icon" /> icon = <XCircleSolid className="text-ui-tag-red-icon" />
break break
case "loading": case "loading":
Icon = <Spinner className="text-ui-tag-blue-icon animate-spin" /> icon = <Spinner className="text-ui-tag-blue-icon animate-spin" />
break break
default: case "info":
Icon = <InformationCircleSolid className="text-ui-fg-base" /> icon = <InformationCircleSolid className="text-ui-fg-base" />
break break
} default:
break
}
if (action && !action.altText) { return (
// eslint-disable-next-line turbo/no-undeclared-env-vars <div className="shadow-elevation-flyout bg-ui-bg-base flex w-fit min-w-[360px] max-w-[440px] overflow-hidden rounded-lg">
if (process.env.NODE_ENV === "development") { <div
console.warn( className={clx("grid flex-1 items-start gap-3 py-3 pl-3", {
"Omitting `altText` from the action is not recommended. Please provide a description for screen readers." "border-r pr-3": hasActionables,
) "pr-6": !hasActionables,
} "grid-cols-[20px_1fr]": !!icon,
} "grid-cols-1": !icon,
})}
return (
<Primitives.Root
ref={ref}
className={clx(
"bg-ui-bg-base border-ui-border-base flex h-fit min-h-[74px] w-full overflow-hidden rounded-md border shadow-[0_4px_12px_rgba(0,0,0,0.05)] md:max-w-[440px]",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full 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",
className
)}
{...props}
> >
<div className="border-ui-border-base flex flex-1 items-start space-x-3 border-r p-4"> {!!icon && (
<span aria-hidden>{Icon}</span> <span className="flex size-5 items-center justify-center" aria-hidden>
<div> {icon}
{title && ( </span>
<Primitives.Title className="text-ui-fg-base txt-compact-small-plus"> )}
{title}
</Primitives.Title>
)}
{description && (
<Primitives.Description className="text-ui-fg-subtle txt-compact-medium">
{description}
</Primitives.Description>
)}
</div>
</div>
<div className="flex flex-col"> <div className="flex flex-col">
{action && ( <span className="txt-compact-small-plus text-ui-fg-base">
<> {title}
<Primitives.Action </span>
altText={action.altText} <span className="txt-small text-ui-fg-subtle text-pretty">
className={clx( {description}
"txt-compact-small-plus text-ui-fg-base bg-ui-bg-base hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed flex flex-1 items-center justify-center px-6 transition-colors", </span>
{ </div>
"text-ui-fg-error": variant === "error", </div>
} {hasActionables && (
)} <div
onClick={(e) => { className={clx("grid grid-cols-1", {
e.preventDefault() "grid-rows-2": !!action && onDismiss,
action.onClick() "grid-rows-1": !action || !onDismiss,
}} })}
type="button" >
> {!!action && (
{action.label} <button
</Primitives.Action> type="button"
<div className="bg-ui-border-base h-px w-full" />
</>
)}
{!disableDismiss && (
<Primitives.Close
className={clx( className={clx(
"txt-compact-small-plus text-ui-fg-subtle bg-ui-bg-base hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed flex flex-1 items-center justify-center px-6 transition-colors", "txt-compact-small-plus text-ui-fg-base bg-ui-bg-base hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed flex items-center justify-center px-4 transition-colors",
{ {
"h-1/2": action, "border-ui-border-base border-b": onDismiss,
"h-full": !action, "text-ui-fg-error": action.variant === "destructive",
} }
)} )}
aria-label="Close" onClick={action.onClick}
> >
Close {action.label}
</Primitives.Close> </button>
)}
{onDismiss && (
<button
type="button"
onClick={() => onDismiss(id)}
className={clx(
"txt-compact-small-plus text-ui-fg-subtle bg-ui-bg-base hover:bg-ui-bg-base-hover active:bg-ui-bg-base-pressed flex items-center justify-center px-4 transition-colors"
)}
>
{dismissLabel}
</button>
)} )}
</div> </div>
</Primitives.Root> )}
) </div>
} )
) }
Toast.displayName = "Toast"
type ToastActionElement = ActionProps
export {
Toast,
ToastProvider,
ToastViewport,
type ToastActionElement,
type ToastProps,
}

View File

@@ -0,0 +1,186 @@
import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react"
import { Button } from "@/components/button"
import { toast } from "@/utils/toast"
import { Toaster } from "./toaster"
const meta: Meta<typeof Toaster> = {
title: "Components/Toaster",
component: Toaster,
parameters: {
layout: "centered",
},
render: (args) => {
const makeMessageToast = () => {
toast("This is a message toast.")
}
const makeInfoToast = () => {
toast.info("This is an info toast.")
}
const makeSuccessToast = () => {
toast.success("This is a success toast.")
}
const makeWarningToast = () => {
toast.warning("This is a warning toast.")
}
const makeErrorToast = () => {
toast.error("This is an error toast.")
}
const makeActionToast = () => {
toast.error("This is an error toast with an action.", {
action: {
label: "Retry",
altText: "Retry the request",
onClick: () => {
console.log("Retrying the request...")
},
},
})
}
const doAsyncStuff = async () => {
return new Promise((resolve, reject) => {
setTimeout(() => {
if (Math.random() > 0.2) {
resolve("Success!")
} else {
reject("Failed!")
}
}, 3000)
})
}
const makeAsyncToast = async () => {
toast.promise(doAsyncStuff, {
loading: "Loading...",
success: "Success!",
error: "Failed!",
})
}
const makeUpdatingToast = () => {
let id: number | string = Math.random()
const retry = async () => {
const coinFlip = Math.random() > 0.5
console.log("retrying", id)
toast.loading("Request in progress", {
id: id,
description: "The request is in progress.",
})
// wait 3 seconds
await new Promise((resolve) => setTimeout(resolve, 3000))
if (coinFlip) {
toast.success("Request succeeded", {
id: id,
description: "The request succeeded.",
})
} else {
toast.error("Request failed", {
id: id,
description:
"Insert the description here. Can be semi-long and still work.",
action: {
label: "Retry",
altText: "Retry the request",
onClick: retry,
},
})
}
}
toast.error("Request failed", {
id: id,
description:
"Insert the description here. Can be semi-long and still work.",
action: {
label: "Retry",
altText: "Retry the request",
onClick: retry,
},
})
}
return (
<div className="size-full">
<div className="flex flex-col gap-y-2">
<Button size="small" variant="secondary" onClick={makeMessageToast}>
Make message toast
</Button>
<Button size="small" variant="secondary" onClick={makeInfoToast}>
Make info toast
</Button>
<Button size="small" variant="secondary" onClick={makeSuccessToast}>
Make success toast
</Button>
<Button size="small" variant="secondary" onClick={makeWarningToast}>
Make warning toast
</Button>
<Button size="small" variant="secondary" onClick={makeErrorToast}>
Make error toast
</Button>
<Button size="small" variant="secondary" onClick={makeActionToast}>
Make action toast
</Button>
<Button size="small" variant="secondary" onClick={makeUpdatingToast}>
Make toast
</Button>
<Button size="small" variant="secondary" onClick={makeAsyncToast}>
Make async toast
</Button>
</div>
<Toaster {...args} />
</div>
)
},
}
export default meta
type Story = StoryObj<typeof Toaster>
export const TopRight: Story = {
args: {
position: "top-right",
},
}
export const TopCenter: Story = {
args: {
position: "top-center",
},
}
export const TopLeft: Story = {
args: {
position: "top-left",
},
}
export const BottomRight: Story = {
args: {
position: "bottom-right",
},
}
export const BottomCenter: Story = {
args: {
position: "bottom-center",
},
}
export const BottomLeft: Story = {
args: {
position: "bottom-left",
},
}

View File

@@ -1,20 +1,41 @@
"use client" "use client"
import * as React from "react" import * as React from "react"
import { Toaster as Primitive } from "sonner"
import { Toast, ToastProvider, ToastViewport } from "@/components/toast" import { clx } from "@/utils/clx"
import { useToast } from "@/hooks/use-toast"
const Toaster = () => { interface ToasterProps
const { toasts } = useToast() extends Omit<
React.ComponentPropsWithoutRef<typeof Primitive>,
| "richColors"
| "closeButton"
| "icons"
| "theme"
| "invert"
| "loadingIcon"
| "cn"
| "toastOptions"
> {}
const Toaster = ({
position = "bottom-right",
gap = 12,
offset = 24,
duration,
...props
}: ToasterProps) => {
return ( return (
<ToastProvider swipeDirection="right"> <Primitive
{toasts.map(({ id, ...props }) => { position={position}
return <Toast key={id} {...props} /> gap={gap}
})} offset={offset}
<ToastViewport /> cn={clx}
</ToastProvider> toastOptions={{
duration,
}}
{...props}
/>
) )
} }

View File

@@ -1 +0,0 @@
export * from "./use-toast"

View File

@@ -1,85 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react"
import * as React from "react"
import { Button } from "@/components/button"
import { Toaster } from "@/components/toaster"
import { useToast } from "./use-toast"
const Demo = () => {
const { toast } = useToast()
const handleUndo = async () => {
// Wait 3 seconds then resolve with true
const confirmed = await new Promise((resolve) => {
setTimeout(() => {
resolve(true)
}, 3000)
})
return confirmed
}
const handleSchedule = () => {
const onAction = async (fn: (update: any) => void) => {
fn({
title: "Processing",
description: "Removing post from queue.",
variant: "loading",
disableDismiss: true,
action: undefined,
})
const confirmed = await handleUndo().then((confirmed) => {
if (confirmed) {
fn({
title: "Success",
description: "Your post was successfully removed from the queue.",
variant: "success",
disableDismiss: false,
})
} else {
fn({
title: "Error",
description:
"Something went wrong, and your post was not removed from the queue. Try again.",
variant: "error",
disableDismiss: false,
})
}
})
}
const t = toast({
title: "Scheduled",
description: "Your post has been scheduled for 12:00 PM",
variant: "success",
action: {
label: "Undo",
onClick: () => onAction(t.update),
altText:
"Alternatively, you can undo this action by navigating to the scheduled posts page, and clicking the unschedule button.",
},
})
}
return (
<div className="min-w-screen flex min-h-screen flex-col items-center justify-center">
<Button onClick={handleSchedule}>Schedule</Button>
<Toaster />
</div>
)
}
const meta: Meta<typeof Demo> = {
title: "Hooks/useToast",
component: Demo,
parameters: {
layout: "centered",
},
}
export default meta
type Story = StoryObj<typeof Demo>
export const Default: Story = {}

View File

@@ -1,188 +0,0 @@
"use client"
import * as React from "react"
import type { ToastActionElement, ToastProps } from "@/components/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_VALUE
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
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),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
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] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { toast, useToast }

View File

@@ -40,11 +40,11 @@ export { Tooltip } from "./components/tooltip"
// Hooks // Hooks
export { usePrompt } from "./hooks/use-prompt" export { usePrompt } from "./hooks/use-prompt"
export { useToast } from "./hooks/use-toast"
export { useToggleState } from "./hooks/use-toggle-state" export { useToggleState } from "./hooks/use-toggle-state"
// Utils // Utils
export { clx } from "./utils/clx" export { clx } from "./utils/clx"
export { toast } from "./utils/toast"
// Types // Types
export * from "./types" export * from "./types"

View File

@@ -1,5 +1,9 @@
// Progress types
export type ProgressStatus = "not-started" | "in-progress" | "completed" export type ProgressStatus = "not-started" | "in-progress" | "completed"
// Calendar types
export type DateRange = { export type DateRange = {
/** /**
* The range's start date. * The range's start date.
@@ -10,3 +14,30 @@ export type DateRange = {
*/ */
to?: Date | undefined to?: Date | undefined
} }
// Toast types
export type ToasterPosition =
| "top-left"
| "top-center"
| "top-right"
| "bottom-left"
| "bottom-center"
| "bottom-right"
export type ToastVariant =
| "info"
| "success"
| "warning"
| "error"
| "loading"
| "message"
export type ToastActionVariant = "default" | "destructive"
export type ToastAction = {
label: string
altText: string
onClick: () => void | Promise<void>
variant?: ToastActionVariant
}

View File

@@ -0,0 +1,214 @@
import { Toast } from "@/components/toast"
import { ToastAction, ToastVariant, ToasterPosition } from "@/types"
import * as React from "react"
import { toast as toastFn } from "sonner"
interface BaseToastProps {
id?: string | number
position?: ToasterPosition
dismissable?: boolean
dismissLabel?: string
duration?: number
}
interface ToastProps extends BaseToastProps {
description?: string
action?: ToastAction
}
function create(variant: ToastVariant, title: string, props: ToastProps = {}) {
return toastFn.custom(
(t) => {
return (
<Toast
id={props.id || t}
title={title}
description={props.description}
onDismiss={
props.dismissable ? () => toastFn.dismiss(props.id || t) : undefined
}
dismissLabel={props.dismissLabel}
variant={variant}
action={props.action}
/>
)
},
{
id: props.id,
position: props.position,
dismissible: props.dismissable,
}
)
}
function message(
/**
* The title of the toast.
*/
title: string,
/**
* The props of the toast.
*/
props: ToastProps = {}
) {
return create("message", title, props)
}
function info(
/**
* The title of the toast.
*/ title: string,
/**
* The props of the toast.
*/
props: ToastProps = {}
) {
return create("info", title, props)
}
function error(
/**
* The title of the toast.
*/ title: string,
/**
* The props of the toast.
*/
props: ToastProps = {}
) {
return create("error", title, props)
}
function success(
/**
* The title of the toast.
*/ title: string,
/**
* The props of the toast.
*/
props: ToastProps = {}
) {
return create("success", title, props)
}
function warning(
/**
* The title of the toast.
*/ title: string,
/**
* The props of the toast.
*/
props: ToastProps = {}
) {
return create("warning", title, props)
}
type LoadingToastProps = Omit<ToastProps, "dismissable" | "dismissLabel">
function loading
/**
* The title of the toast.
*/(
title: string,
/**
* The props of the toast.
*/
props: ToastProps = {}
) {
return create("loading", title, { ...props, dismissable: false })
}
type PromiseStateProps =
| string
| {
title: string
description?: string
}
interface PromiseToastProps extends BaseToastProps {
loading: PromiseStateProps
success: PromiseStateProps
error: PromiseStateProps
}
function createUniqueID() {
return Math.random().toString(36).slice(2, 8)
}
async function promise<TData>(
/**
* The promise to be resolved.
*/
promise: Promise<TData> | (() => Promise<TData>),
/**
* The props of the toast.
*/
props: PromiseToastProps
) {
let id: string | number | undefined = props.id || createUniqueID()
let shouldDismiss = id !== undefined
id = create(
"loading",
typeof props.loading === "string" ? props.loading : props.loading.title,
{
id: id,
position: props.position,
description:
typeof props.loading === "string"
? undefined
: props.loading.description,
duration: Infinity,
}
)
const p = promise instanceof Promise ? promise : promise()
p.then(() => {
shouldDismiss = false
create(
"success",
typeof props.success === "string" ? props.success : props.success.title,
{
id: id,
position: props.position,
description:
typeof props.success === "string"
? undefined
: props.success.description,
}
)
})
.catch(() => {
shouldDismiss = false
create(
"error",
typeof props.error === "string" ? props.error : props.error.title,
{
id: id,
position: props.position,
description:
typeof props.error === "string"
? undefined
: props.error.description,
}
)
})
.finally(() => {
if (shouldDismiss) {
toastFn.dismiss(id)
id = undefined
}
})
return id
}
export const toast = Object.assign(message, {
info,
error,
warning,
success,
promise,
loading,
dismiss: toastFn.dismiss,
})

155
yarn.lock
View File

@@ -9057,7 +9057,6 @@ __metadata:
"@radix-ui/react-slot": ^1.0.2 "@radix-ui/react-slot": ^1.0.2
"@radix-ui/react-switch": ^1.0.3 "@radix-ui/react-switch": ^1.0.3
"@radix-ui/react-tabs": ^1.0.4 "@radix-ui/react-tabs": ^1.0.4
"@radix-ui/react-toast": ^1.1.4
"@radix-ui/react-tooltip": ^1.0.6 "@radix-ui/react-tooltip": ^1.0.6
"@react-aria/datepicker": ^3.5.0 "@react-aria/datepicker": ^3.5.0
"@react-stately/datepicker": ^3.5.0 "@react-stately/datepicker": ^3.5.0
@@ -9078,7 +9077,7 @@ __metadata:
"@types/react-dom": ^18.2.0 "@types/react-dom": ^18.2.0
"@vitejs/plugin-react": ^4.0.1 "@vitejs/plugin-react": ^4.0.1
"@vitest/coverage-v8": ^0.32.2 "@vitest/coverage-v8": ^0.32.2
autoprefixer: ^10.4.17 autoprefixer: ^10.4.19
chromatic: ^6.20.0 chromatic: ^6.20.0
clsx: ^1.2.1 clsx: ^1.2.1
copy-to-clipboard: ^3.3.3 copy-to-clipboard: ^3.3.3
@@ -9087,7 +9086,7 @@ __metadata:
eslint: ^7.32.0 eslint: ^7.32.0
eslint-plugin-storybook: ^0.6.12 eslint-plugin-storybook: ^0.6.12
jsdom: ^22.1.0 jsdom: ^22.1.0
postcss: ^8.4.33 postcss: ^8.4.38
prism-react-renderer: ^2.0.6 prism-react-renderer: ^2.0.6
prismjs: ^1.29.0 prismjs: ^1.29.0
prop-types: ^15.8.1 prop-types: ^15.8.1
@@ -9097,9 +9096,10 @@ __metadata:
react-dom: ^18.2.0 react-dom: ^18.2.0
resize-observer-polyfill: ^1.5.1 resize-observer-polyfill: ^1.5.1
rimraf: ^5.0.1 rimraf: ^5.0.1
sonner: ^1.4.41
storybook: ^7.0.23 storybook: ^7.0.23
tailwind-merge: ^2.2.1 tailwind-merge: ^2.2.1
tailwindcss: ^3.4.1 tailwindcss: ^3.4.3
tsc-alias: ^1.8.7 tsc-alias: ^1.8.7
typescript: ^5.1.6 typescript: ^5.1.6
vite: ^4.3.9 vite: ^4.3.9
@@ -11418,37 +11418,6 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"@radix-ui/react-toast@npm:^1.1.4":
version: 1.1.4
resolution: "@radix-ui/react-toast@npm:1.1.4"
dependencies:
"@babel/runtime": ^7.13.10
"@radix-ui/primitive": 1.0.1
"@radix-ui/react-collection": 1.0.3
"@radix-ui/react-compose-refs": 1.0.1
"@radix-ui/react-context": 1.0.1
"@radix-ui/react-dismissable-layer": 1.0.4
"@radix-ui/react-portal": 1.0.3
"@radix-ui/react-presence": 1.0.1
"@radix-ui/react-primitive": 1.0.3
"@radix-ui/react-use-callback-ref": 1.0.1
"@radix-ui/react-use-controllable-state": 1.0.1
"@radix-ui/react-use-layout-effect": 1.0.1
"@radix-ui/react-visually-hidden": 1.0.3
peerDependencies:
"@types/react": "*"
"@types/react-dom": "*"
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
"@types/react":
optional: true
"@types/react-dom":
optional: true
checksum: bb8282ca87820763d3912e11c1693aa414907d08dabd10a07e77ec0ac06a27e9eed76a591fb90dec147454e674c4c57a961b541e2de76c8125ab1aefaab62c2e
languageName: node
linkType: hard
"@radix-ui/react-toggle-group@npm:1.0.4": "@radix-ui/react-toggle-group@npm:1.0.4":
version: 1.0.4 version: 1.0.4
resolution: "@radix-ui/react-toggle-group@npm:1.0.4" resolution: "@radix-ui/react-toggle-group@npm:1.0.4"
@@ -20976,6 +20945,24 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"autoprefixer@npm:^10.4.19":
version: 10.4.19
resolution: "autoprefixer@npm:10.4.19"
dependencies:
browserslist: ^4.23.0
caniuse-lite: ^1.0.30001599
fraction.js: ^4.3.7
normalize-range: ^0.1.2
picocolors: ^1.0.0
postcss-value-parser: ^4.2.0
peerDependencies:
postcss: ^8.1.0
bin:
autoprefixer: bin/autoprefixer
checksum: fe0178eb8b1da4f15c6535cd329926609b22d1811e047371dccce50563623f8075dd06fb167daff059e4228da651b0bdff6d9b44281541eaf0ce0b79125bfd19
languageName: node
linkType: hard
"autoprefixer@npm:^9.8.6": "autoprefixer@npm:^9.8.6":
version: 9.8.8 version: 9.8.8
resolution: "autoprefixer@npm:9.8.8" resolution: "autoprefixer@npm:9.8.8"
@@ -22388,6 +22375,20 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"browserslist@npm:^4.23.0":
version: 4.23.0
resolution: "browserslist@npm:4.23.0"
dependencies:
caniuse-lite: ^1.0.30001587
electron-to-chromium: ^1.4.668
node-releases: ^2.0.14
update-browserslist-db: ^1.0.13
bin:
browserslist: cli.js
checksum: 8e9cc154529062128d02a7af4d8adeead83ca1df8cd9ee65a88e2161039f3d68a4d40fea7353cab6bae4c16182dec2fdd9a1cf7dc2a2935498cee1af0e998943
languageName: node
linkType: hard
"bs-logger@npm:0.x": "bs-logger@npm:0.x":
version: 0.2.6 version: 0.2.6
resolution: "bs-logger@npm:0.2.6" resolution: "bs-logger@npm:0.2.6"
@@ -22915,6 +22916,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"caniuse-lite@npm:^1.0.30001587, caniuse-lite@npm:^1.0.30001599":
version: 1.0.30001610
resolution: "caniuse-lite@npm:1.0.30001610"
checksum: 015956a0bf2e3e233da3dc00c5632bbb4d416bcd6ced2f839e33e45b197a856234f97cb046e7427b83d7e3a3d6df314dfab1c86eb9d970970e00ad85a50b4933
languageName: node
linkType: hard
"capital-case@npm:^1.0.4": "capital-case@npm:^1.0.4":
version: 1.0.4 version: 1.0.4
resolution: "capital-case@npm:1.0.4" resolution: "capital-case@npm:1.0.4"
@@ -26256,6 +26264,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"electron-to-chromium@npm:^1.4.668":
version: 1.4.736
resolution: "electron-to-chromium@npm:1.4.736"
checksum: f3acb515dcb9333b318f9a8ee80a0c3975162da6cc45d9c00152e0de7004b2bfe1246fe6cdf0c41a1b7016780bd4324aff848830aba4227cb55480c1aa32d248
languageName: node
linkType: hard
"elliptic@npm:^6.5.3": "elliptic@npm:^6.5.3":
version: 6.5.4 version: 6.5.4
resolution: "elliptic@npm:6.5.4" resolution: "elliptic@npm:6.5.4"
@@ -35839,6 +35854,15 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"jiti@npm:^1.21.0":
version: 1.21.0
resolution: "jiti@npm:1.21.0"
bin:
jiti: bin/jiti.js
checksum: 7f361219fe6c7a5e440d5f1dba4ab763a5538d2df8708cdc22561cf25ea3e44b837687931fca7cdd8cdd9f567300e90be989dd1321650045012d8f9ed6aab07f
languageName: node
linkType: hard
"jmespath@npm:0.16.0": "jmespath@npm:0.16.0":
version: 0.16.0 version: 0.16.0
resolution: "jmespath@npm:0.16.0" resolution: "jmespath@npm:0.16.0"
@@ -42956,6 +42980,17 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"postcss@npm:^8.4.38":
version: 8.4.38
resolution: "postcss@npm:8.4.38"
dependencies:
nanoid: ^3.3.7
picocolors: ^1.0.0
source-map-js: ^1.2.0
checksum: 955407b8f70cf0c14acf35dab3615899a2a60a26718a63c848cf3c29f2467b0533991b985a2b994430d890bd7ec2b1963e36352b0774a19143b5f591540f7c06
languageName: node
linkType: hard
"postgres-array@npm:~2.0.0": "postgres-array@npm:~2.0.0":
version: 2.0.0 version: 2.0.0
resolution: "postgres-array@npm:2.0.0" resolution: "postgres-array@npm:2.0.0"
@@ -47357,6 +47392,16 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"sonner@npm:^1.4.41":
version: 1.4.41
resolution: "sonner@npm:1.4.41"
peerDependencies:
react: ^18.0.0
react-dom: ^18.0.0
checksum: 31467ecab0fcbd5161fb76d158c698841b524686f7f598eecbc2a58d76bc496b5d5242df4350e274575aa3df59428d3aacd534415c968b19fc309c192c443330
languageName: node
linkType: hard
"sort-keys@npm:^5.0.0": "sort-keys@npm:^5.0.0":
version: 5.0.0 version: 5.0.0
resolution: "sort-keys@npm:5.0.0" resolution: "sort-keys@npm:5.0.0"
@@ -47387,6 +47432,13 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"source-map-js@npm:^1.2.0":
version: 1.2.0
resolution: "source-map-js@npm:1.2.0"
checksum: 7e5f896ac10a3a50fe2898e5009c58ff0dc102dcb056ed27a354623a0ece8954d4b2649e1a1b2b52ef2e161d26f8859c7710350930751640e71e374fe2d321a4
languageName: node
linkType: hard
"source-map-resolve@npm:^0.5.0": "source-map-resolve@npm:^0.5.0":
version: 0.5.3 version: 0.5.3
resolution: "source-map-resolve@npm:0.5.3" resolution: "source-map-resolve@npm:0.5.3"
@@ -48872,6 +48924,39 @@ __metadata:
languageName: node languageName: node
linkType: hard linkType: hard
"tailwindcss@npm:^3.4.3":
version: 3.4.3
resolution: "tailwindcss@npm:3.4.3"
dependencies:
"@alloc/quick-lru": ^5.2.0
arg: ^5.0.2
chokidar: ^3.5.3
didyoumean: ^1.2.2
dlv: ^1.1.3
fast-glob: ^3.3.0
glob-parent: ^6.0.2
is-glob: ^4.0.3
jiti: ^1.21.0
lilconfig: ^2.1.0
micromatch: ^4.0.5
normalize-path: ^3.0.0
object-hash: ^3.0.0
picocolors: ^1.0.0
postcss: ^8.4.23
postcss-import: ^15.1.0
postcss-js: ^4.0.1
postcss-load-config: ^4.0.1
postcss-nested: ^6.0.1
postcss-selector-parser: ^6.0.11
resolve: ^1.22.2
sucrase: ^3.32.0
bin:
tailwind: lib/cli.js
tailwindcss: lib/cli.js
checksum: 11e5546494f2888f693ebaa271b218b3a8e52fe59d7b629e54f2dffd6eaafd5ded2e9f0c37ad04e6a866dffb2b116d91becebad77e1441beee8bf016bb2392f9
languageName: node
linkType: hard
"tapable@npm:^1.0.0, tapable@npm:^1.1.3": "tapable@npm:^1.0.0, tapable@npm:^1.1.3":
version: 1.1.3 version: 1.1.3
resolution: "tapable@npm:1.1.3" resolution: "tapable@npm:1.1.3"