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:
committed by
GitHub
parent
379ff7a36d
commit
c3260a2c5a
@@ -1,7 +1,7 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react"
|
||||
import * as React from "react"
|
||||
|
||||
import { Toast, ToastProvider, ToastViewport } from "./toast"
|
||||
import { Toast } from "./toast"
|
||||
|
||||
const meta: Meta<typeof Toast> = {
|
||||
title: "Components/Toast",
|
||||
@@ -10,13 +10,7 @@ const meta: Meta<typeof Toast> = {
|
||||
layout: "centered",
|
||||
},
|
||||
render: (args) => {
|
||||
return (
|
||||
<ToastProvider>
|
||||
<ToastViewport>
|
||||
<Toast {...args} />
|
||||
</ToastViewport>
|
||||
</ToastProvider>
|
||||
)
|
||||
return <Toast {...args} />
|
||||
},
|
||||
}
|
||||
|
||||
@@ -28,7 +22,7 @@ export const Information: Story = {
|
||||
args: {
|
||||
title: "Label",
|
||||
description: "The quick brown fox jumps over a lazy dog.",
|
||||
open: true,
|
||||
variant: "info",
|
||||
},
|
||||
}
|
||||
|
||||
@@ -37,7 +31,6 @@ export const Warning: Story = {
|
||||
title: "Label",
|
||||
description: "The quick brown fox jumps over a lazy dog.",
|
||||
variant: "warning",
|
||||
open: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -46,7 +39,6 @@ export const Error: Story = {
|
||||
title: "Label",
|
||||
description: "The quick brown fox jumps over a lazy dog.",
|
||||
variant: "error",
|
||||
open: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -55,7 +47,6 @@ export const Success: Story = {
|
||||
title: "Label",
|
||||
description: "The quick brown fox jumps over a lazy dog.",
|
||||
variant: "success",
|
||||
open: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -64,7 +55,6 @@ export const Loading: Story = {
|
||||
title: "Label",
|
||||
description: "The quick brown fox jumps over a lazy dog.",
|
||||
variant: "loading",
|
||||
open: true,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -73,7 +63,6 @@ export const WithAction: Story = {
|
||||
title: "Scheduled meeting",
|
||||
description: "The meeting has been added to your calendar.",
|
||||
variant: "success",
|
||||
open: true,
|
||||
action: {
|
||||
altText: "Undo adding meeting to calendar",
|
||||
onClick: () => {},
|
||||
|
||||
@@ -1,189 +1,110 @@
|
||||
"use client"
|
||||
|
||||
import {
|
||||
CheckCircleSolid,
|
||||
ExclamationCircleSolid,
|
||||
InformationCircleSolid,
|
||||
Spinner,
|
||||
XCircleSolid,
|
||||
} from "@medusajs/icons"
|
||||
import * as Primitives from "@radix-ui/react-toast"
|
||||
import { CheckCircleSolid, ExclamationCircleSolid, InformationCircleSolid, Spinner, XCircleSolid } from "@medusajs/icons"
|
||||
import * as React from "react"
|
||||
|
||||
import type { ToastAction, ToastVariant } from "@/types"
|
||||
import { clx } from "@/utils/clx"
|
||||
|
||||
const ToastProvider = Primitives.Provider
|
||||
ToastProvider.displayName = "ToastProvider"
|
||||
|
||||
const ToastViewport = React.forwardRef<
|
||||
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
|
||||
interface ToastComponentProps {
|
||||
id: string | number
|
||||
variant?: ToastVariant
|
||||
title: string
|
||||
description?: string
|
||||
action?: ActionProps
|
||||
disableDismiss?: boolean
|
||||
action?: ToastAction
|
||||
onDismiss?: (id?: string | number) => void
|
||||
dismissLabel?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* This component is based on the [Radix UI Toast](https://www.radix-ui.com/primitives/docs/components/toast) primitives.
|
||||
*
|
||||
* @excludeExternal
|
||||
*/
|
||||
const Toast = React.forwardRef<
|
||||
React.ElementRef<typeof Primitives.Root>,
|
||||
ToastProps
|
||||
>(
|
||||
(
|
||||
{
|
||||
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
|
||||
export const Toast = ({
|
||||
id,
|
||||
variant = "info",
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
onDismiss,
|
||||
dismissLabel = "Close",
|
||||
}: ToastComponentProps) => {
|
||||
const hasActionables = !!action || onDismiss
|
||||
let icon = undefined
|
||||
|
||||
switch (variant) {
|
||||
case "success":
|
||||
Icon = <CheckCircleSolid className="text-ui-tag-green-icon" />
|
||||
break
|
||||
case "warning":
|
||||
Icon = <ExclamationCircleSolid className="text-ui-tag-orange-icon" />
|
||||
break
|
||||
case "error":
|
||||
Icon = <XCircleSolid className="text-ui-tag-red-icon" />
|
||||
break
|
||||
case "loading":
|
||||
Icon = <Spinner className="text-ui-tag-blue-icon animate-spin" />
|
||||
break
|
||||
default:
|
||||
Icon = <InformationCircleSolid className="text-ui-fg-base" />
|
||||
break
|
||||
}
|
||||
switch (variant) {
|
||||
case "success":
|
||||
icon = <CheckCircleSolid className="text-ui-tag-green-icon" />
|
||||
break
|
||||
case "warning":
|
||||
icon = <ExclamationCircleSolid className="text-ui-tag-orange-icon" />
|
||||
break
|
||||
case "error":
|
||||
icon = <XCircleSolid className="text-ui-tag-red-icon" />
|
||||
break
|
||||
case "loading":
|
||||
icon = <Spinner className="text-ui-tag-blue-icon animate-spin" />
|
||||
break
|
||||
case "info":
|
||||
icon = <InformationCircleSolid className="text-ui-fg-base" />
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
|
||||
if (action && !action.altText) {
|
||||
// eslint-disable-next-line turbo/no-undeclared-env-vars
|
||||
if (process.env.NODE_ENV === "development") {
|
||||
console.warn(
|
||||
"Omitting `altText` from the action is not recommended. Please provide a description for screen readers."
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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}
|
||||
return (
|
||||
<div className="shadow-elevation-flyout bg-ui-bg-base flex w-fit min-w-[360px] max-w-[440px] overflow-hidden rounded-lg">
|
||||
<div
|
||||
className={clx("grid flex-1 items-start gap-3 py-3 pl-3", {
|
||||
"border-r pr-3": hasActionables,
|
||||
"pr-6": !hasActionables,
|
||||
"grid-cols-[20px_1fr]": !!icon,
|
||||
"grid-cols-1": !icon,
|
||||
})}
|
||||
>
|
||||
<div className="border-ui-border-base flex flex-1 items-start space-x-3 border-r p-4">
|
||||
<span aria-hidden>{Icon}</span>
|
||||
<div>
|
||||
{title && (
|
||||
<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>
|
||||
{!!icon && (
|
||||
<span className="flex size-5 items-center justify-center" aria-hidden>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
{action && (
|
||||
<>
|
||||
<Primitives.Action
|
||||
altText={action.altText}
|
||||
className={clx(
|
||||
"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",
|
||||
{
|
||||
"text-ui-fg-error": variant === "error",
|
||||
}
|
||||
)}
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
action.onClick()
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
{action.label}
|
||||
</Primitives.Action>
|
||||
<div className="bg-ui-border-base h-px w-full" />
|
||||
</>
|
||||
)}
|
||||
{!disableDismiss && (
|
||||
<Primitives.Close
|
||||
<span className="txt-compact-small-plus text-ui-fg-base">
|
||||
{title}
|
||||
</span>
|
||||
<span className="txt-small text-ui-fg-subtle text-pretty">
|
||||
{description}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{hasActionables && (
|
||||
<div
|
||||
className={clx("grid grid-cols-1", {
|
||||
"grid-rows-2": !!action && onDismiss,
|
||||
"grid-rows-1": !action || !onDismiss,
|
||||
})}
|
||||
>
|
||||
{!!action && (
|
||||
<button
|
||||
type="button"
|
||||
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,
|
||||
"h-full": !action,
|
||||
"border-ui-border-base border-b": onDismiss,
|
||||
"text-ui-fg-error": action.variant === "destructive",
|
||||
}
|
||||
)}
|
||||
aria-label="Close"
|
||||
onClick={action.onClick}
|
||||
>
|
||||
Close
|
||||
</Primitives.Close>
|
||||
{action.label}
|
||||
</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>
|
||||
</Primitives.Root>
|
||||
)
|
||||
}
|
||||
)
|
||||
Toast.displayName = "Toast"
|
||||
|
||||
type ToastActionElement = ActionProps
|
||||
|
||||
export {
|
||||
Toast,
|
||||
ToastProvider,
|
||||
ToastViewport,
|
||||
type ToastActionElement,
|
||||
type ToastProps,
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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",
|
||||
},
|
||||
}
|
||||
@@ -1,20 +1,41 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Toaster as Primitive } from "sonner"
|
||||
|
||||
import { Toast, ToastProvider, ToastViewport } from "@/components/toast"
|
||||
import { useToast } from "@/hooks/use-toast"
|
||||
import { clx } from "@/utils/clx"
|
||||
|
||||
const Toaster = () => {
|
||||
const { toasts } = useToast()
|
||||
interface ToasterProps
|
||||
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 (
|
||||
<ToastProvider swipeDirection="right">
|
||||
{toasts.map(({ id, ...props }) => {
|
||||
return <Toast key={id} {...props} />
|
||||
})}
|
||||
<ToastViewport />
|
||||
</ToastProvider>
|
||||
<Primitive
|
||||
position={position}
|
||||
gap={gap}
|
||||
offset={offset}
|
||||
cn={clx}
|
||||
toastOptions={{
|
||||
duration,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user