chore: import upstream snapshot with attribution
This commit is contained in:
@@ -0,0 +1,174 @@
|
||||
import { EnvelopeIcon, ExclamationCircleIcon, XMarkIcon } from "@heroicons/react/20/solid";
|
||||
import { CheckCircleIcon } from "@heroicons/react/24/solid";
|
||||
import { useSearchParams } from "@remix-run/react";
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { useTypedLoaderData } from "remix-typedjson";
|
||||
import { Toaster, toast } from "sonner";
|
||||
import { type ToastMessageAction } from "~/models/message.server";
|
||||
import { type loader } from "~/root";
|
||||
import { cn } from "~/utils/cn";
|
||||
import { Button, LinkButton } from "./Buttons";
|
||||
import { Header2 } from "./Headers";
|
||||
import { Paragraph } from "./Paragraph";
|
||||
|
||||
const defaultToastDuration = 5000;
|
||||
const permanentToastDuration = 60 * 60 * 24 * 1000;
|
||||
|
||||
export function Toast() {
|
||||
const { toastMessage } = useTypedLoaderData<typeof loader>();
|
||||
useEffect(() => {
|
||||
if (!toastMessage) {
|
||||
return;
|
||||
}
|
||||
const { message, type, options } = toastMessage;
|
||||
|
||||
const ephemeral = options.action ? false : options.ephemeral;
|
||||
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastUI
|
||||
variant={type}
|
||||
message={message}
|
||||
t={t as string}
|
||||
title={options.title}
|
||||
action={options.action}
|
||||
/>
|
||||
),
|
||||
{
|
||||
duration: ephemeral ? defaultToastDuration : permanentToastDuration,
|
||||
}
|
||||
);
|
||||
}, [toastMessage]);
|
||||
|
||||
return <Toaster />;
|
||||
}
|
||||
|
||||
export function useToast() {
|
||||
return useMemo(
|
||||
() => ({
|
||||
success(message: string, options?: { title?: string; ephemeral?: boolean }) {
|
||||
const ephemeral = options?.ephemeral ?? true;
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastUI variant="success" message={message} t={t as string} title={options?.title} />
|
||||
),
|
||||
{ duration: ephemeral ? defaultToastDuration : permanentToastDuration }
|
||||
);
|
||||
},
|
||||
error(message: string, options?: { title?: string; ephemeral?: boolean }) {
|
||||
const ephemeral = options?.ephemeral ?? true;
|
||||
toast.custom(
|
||||
(t) => (
|
||||
<ToastUI variant="error" message={message} t={t as string} title={options?.title} />
|
||||
),
|
||||
{ duration: ephemeral ? defaultToastDuration : permanentToastDuration }
|
||||
);
|
||||
},
|
||||
}),
|
||||
[]
|
||||
);
|
||||
}
|
||||
|
||||
export function ToastUI({
|
||||
variant,
|
||||
message,
|
||||
t,
|
||||
toastWidth = 356, // Default width, matches what sonner provides by default
|
||||
title,
|
||||
action,
|
||||
}: {
|
||||
variant: "error" | "success";
|
||||
message: string;
|
||||
t: string;
|
||||
toastWidth?: string | number;
|
||||
title?: string;
|
||||
action?: ToastMessageAction;
|
||||
}) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"self-end rounded-md border border-grid-bright bg-background-dimmed",
|
||||
variant === "success" && "border-success",
|
||||
variant === "error" && "border-error"
|
||||
)}
|
||||
style={{
|
||||
width: toastWidth,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className={cn("flex w-full gap-2 rounded-lg p-3", title ? "items-start" : "items-center")}
|
||||
>
|
||||
{variant === "success" ? (
|
||||
<CheckCircleIcon className={cn("size-4 min-w-4 text-success", title && "mt-1")} />
|
||||
) : (
|
||||
<ExclamationCircleIcon className={cn("size-4 min-w-4 text-error", title && "mt-1")} />
|
||||
)}
|
||||
<div className="flex flex-col">
|
||||
{title && <Header2 className="pt-0">{title}</Header2>}
|
||||
<Paragraph
|
||||
variant={title ? "small/dimmed" : "small/bright"}
|
||||
className={title ? "pb-1 pt-0.5" : ""}
|
||||
>
|
||||
{message}
|
||||
</Paragraph>
|
||||
<Action action={action} toastId={t} className="my-2" />
|
||||
</div>
|
||||
<button
|
||||
className={cn(
|
||||
"-mr-1 ms-auto rounded p-2 text-text-dimmed transition hover:text-text-bright",
|
||||
title && "-mt-1"
|
||||
)}
|
||||
onClick={() => toast.dismiss(t)}
|
||||
>
|
||||
<XMarkIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Action({
|
||||
action,
|
||||
toastId,
|
||||
className,
|
||||
}: {
|
||||
action?: ToastMessageAction;
|
||||
toastId: string;
|
||||
className?: string;
|
||||
}) {
|
||||
const [_, setSearchParams] = useSearchParams();
|
||||
|
||||
if (!action) return null;
|
||||
|
||||
switch (action.action.type) {
|
||||
case "link": {
|
||||
return (
|
||||
<LinkButton
|
||||
className={className}
|
||||
variant={action.variant ?? "secondary/small"}
|
||||
to={action.action.path}
|
||||
>
|
||||
{action.label}
|
||||
</LinkButton>
|
||||
);
|
||||
}
|
||||
case "help": {
|
||||
const feedbackType = action.action.feedbackType;
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
variant={action.variant ?? "secondary/small"}
|
||||
LeadingIcon={EnvelopeIcon}
|
||||
onClick={() => {
|
||||
setSearchParams({
|
||||
feedbackPanel: feedbackType,
|
||||
});
|
||||
toast.dismiss(toastId);
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user