feat(sonner): rewrite toast to use native api

- Simplify toast API to match sonner's interface
- Add support for colored backgrounds and custom durations
- Improve icon rendering with unique IDs to prevent conflicts
- Remove unused code and consolidate styles
- Update stories to reflect new API changes
This commit is contained in:
icarus 2025-12-18 22:07:48 +08:00
parent 36a63b4331
commit 45865a0c2e
No known key found for this signature in database
GPG Key ID: D4AF089AAEC25D18
2 changed files with 601 additions and 471 deletions

View File

@ -1,7 +1,8 @@
import { cn } from '@cherrystudio/ui/utils'
import { cva } from 'class-variance-authority'
import { Loader2Icon } from 'lucide-react'
import { type ReactNode, type SVGProps, useCallback, useMemo } from 'react'
import { merge } from 'lodash'
import { type ReactNode, type SVGProps, useId } from 'react'
import type { Action, ExternalToast, ToastClassnames } from 'sonner'
import { toast as sonnerToast, Toaster as Sonner, type ToasterProps } from 'sonner'
const InfoIcon = ({ className }: SVGProps<SVGSVGElement>) => (
@ -70,135 +71,159 @@ const InfoIcon = ({ className }: SVGProps<SVGSVGElement>) => (
</svg>
)
const WarningIcon = ({ className }: SVGProps<SVGSVGElement>) => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<g filter="url(#filter0_dd_1669_13487)">
<path
d="M15.728 22H8.272C8.00681 21.9999 7.75249 21.8946 7.565 21.707L2.293 16.435C2.10545 16.2475 2.00006 15.9932 2 15.728V8.272C2.00006 8.00681 2.10545 7.75249 2.293 7.565L7.565 2.293C7.75249 2.10545 8.00681 2.00006 8.272 2H15.728C15.9932 2.00006 16.2475 2.10545 16.435 2.293L21.707 7.565C21.8946 7.75249 21.9999 8.00681 22 8.272V15.728C21.9999 15.9932 21.8946 16.2475 21.707 16.435L16.435 21.707C16.2475 21.8946 15.9932 21.9999 15.728 22Z"
fill="url(#paint0_linear_1669_13487)"
/>
<path
d="M12 17C12.5523 17 13 16.5523 13 16C13 15.4477 12.5523 15 12 15C11.4477 15 11 15.4477 11 16C11 16.5523 11.4477 17 12 17Z"
fill="#FAFAFA"
/>
<path
d="M12 13C11.7348 13 11.4804 12.8946 11.2929 12.7071C11.1054 12.5196 11 12.2652 11 12V8C11 7.73478 11.1054 7.48043 11.2929 7.29289C11.4804 7.10536 11.7348 7 12 7C12.2652 7 12.5196 7.10536 12.7071 7.29289C12.8946 7.48043 13 7.73478 13 8V12C13 12.2652 12.8946 12.5196 12.7071 12.7071C12.5196 12.8946 12.2652 13 12 13Z"
fill="#FAFAFA"
/>
</g>
<defs>
<filter
id="filter0_dd_1669_13487"
x="-3"
y="-2"
width="30"
height="30"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1" />
<feGaussianBlur stdDeviation="1.5" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1669_13487" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology radius="2" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_1669_13487" />
<feOffset dy="1" />
<feGaussianBlur stdDeviation="1.5" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0" />
<feBlend mode="normal" in2="effect1_dropShadow_1669_13487" result="effect2_dropShadow_1669_13487" />
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_1669_13487" result="shape" />
</filter>
<linearGradient id="paint0_linear_1669_13487" x1="12" y1="12" x2="12" y2="30.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F59E0B" />
<stop offset="1" stop-color="white" />
</linearGradient>
</defs>
</svg>
)
const SuccessIcon = ({ className }: SVGProps<SVGSVGElement>) => (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
<mask id="mask0_1669_13491" style={{ maskType: 'luminance' }} maskUnits="userSpaceOnUse" x="0" y="0">
<path d="M24 0H0V24H24V0Z" fill="white" />
</mask>
<g mask="url(#mask0_1669_13491)">
<foreignObject x="-3" y="-2">
<div
// xmlns="http://www.w3.org/1999/xhtml"
style={{
backdropFilter: 'blur(2px)',
clipPath: 'url(#bgblur_0_1669_13491_clip_path)',
height: '100%',
width: '100%'
}}></div>
</foreignObject>
<g filter="url(#filter0_dd_1669_13491)" data-figma-bg-blur-radius="4">
const WarningIcon = ({ className, ...props }: SVGProps<SVGSVGElement>) => {
// Remove colons to prevent ID recognition issues in some CSS environments
const id = useId().replace(/:/g, '')
const filterId = `filter_${id}`
const maskId = `mask_${id}`
const gradientId = `paint_${id}`
return (
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className={className}
{...props}>
<defs>
<mask id={maskId}>
{/* Show white part */}
<rect width="24" height="24" fill="white" />
{/* Clip black part */}
<g fill="black">
<path d="M12 17C12.5523 17 13 16.5523 13 16C13 15.4477 12.5523 15 12 15C11.4477 15 11 15.4477 11 16C11 16.5523 11.4477 17 12 17Z" />
<path d="M12 13C11.7348 13 11.4804 12.8946 11.2929 12.7071C11.1054 12.5196 11 12.2652 11 12V8C11 7.73478 11.1054 7.48043 11.2929 7.29289C11.4804 7.10536 11.7348 7 12 7C12.2652 7 12.5196 7.10536 12.7071 7.29289C12.8946 7.48043 13 7.73478 13 8V12C13 12.2652 12.8946 12.5196 12.7071 12.7071C12.5196 12.8946 12.2652 13 12 13Z" />
</g>
</mask>
<filter
id={filterId}
x="-3"
y="-2"
width="30"
height="30"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1" />
<feGaussianBlur stdDeviation="1.5" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow" />
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
</filter>
<linearGradient id={gradientId} x1="12" y1="12" x2="12" y2="30.5" gradientUnits="userSpaceOnUse">
<stop stopColor="#F59E0B" />
<stop offset="1" stopColor="white" />
</linearGradient>
</defs>
<g filter={`url(#${filterId})`}>
<path
d="M13.2121 2.57414C12.5853 1.80862 11.4146 1.80862 10.788 2.57414L9.90009 3.65856C9.83924 3.73288 9.73773 3.76009 9.64787 3.72614L8.33677 3.23092C7.41123 2.88134 6.39741 3.46667 6.23738 4.44301L6.0107 5.82606C5.99517 5.92086 5.92086 5.99516 5.82606 6.0107L4.44301 6.23738C3.46668 6.39741 2.88134 7.41122 3.23092 8.33676L3.72614 9.64787C3.76009 9.73773 3.73288 9.83924 3.65856 9.90009L2.57414 10.7879C1.80862 11.4147 1.80862 12.5854 2.57414 13.2121L3.65856 14.0999C3.73288 14.1608 3.76009 14.2623 3.72614 14.3522L3.23092 15.6633C2.88135 16.5888 3.46667 17.6026 4.44301 17.7627L5.82606 17.9893C5.92086 18.0049 5.99517 18.0792 6.0107 18.174L6.23738 19.557C6.39741 20.5333 7.41122 21.1186 8.33677 20.7691L9.64787 20.2739C9.73773 20.24 9.83924 20.2671 9.90009 20.3415L10.788 21.4259C11.4146 22.1914 12.5853 22.1914 13.2121 21.4259L14.0999 20.3415C14.1608 20.2671 14.2623 20.24 14.3521 20.2739L15.6633 20.7691C16.5888 21.1186 17.6027 20.5333 17.7626 19.557L17.9894 18.174C18.0049 18.0792 18.0791 18.0049 18.1739 17.9893L19.557 17.7627C20.5334 17.6026 21.1187 16.5888 20.7691 15.6633L20.2739 14.3522C20.2399 14.2623 20.2671 14.1608 20.3414 14.0999L21.4259 13.2121C22.1914 12.5854 22.1914 11.4147 21.4259 10.7879L20.3414 9.90009C20.2671 9.83924 20.2399 9.73773 20.2739 9.64787L20.7691 8.33676C21.1187 7.41122 20.5334 6.39741 19.557 6.23738L18.1739 6.0107C18.0791 5.99516 18.0049 5.92086 17.9894 5.82606L17.7626 4.44301C17.6027 3.46668 16.5888 2.88134 15.6633 3.23092L14.3521 3.72614C14.2623 3.76009 14.1608 3.73288 14.0999 3.65856L13.2121 2.57414Z"
fill="url(#paint0_linear_1669_13491)"
d="M15.728 22H8.272C8.00681 21.9999 7.75249 21.8946 7.565 21.707L2.293 16.435C2.10545 16.2475 2.00006 15.9932 2 15.728V8.272C2.00006 8.00681 2.10545 7.75249 2.293 7.565L7.565 2.293C7.75249 2.10545 8.00681 2.00006 8.272 2H15.728C15.9932 2.00006 16.2475 2.10545 16.435 2.293L21.707 7.565C21.8946 7.75249 21.9999 8.00681 22 8.272V15.728C21.9999 15.9932 21.8946 16.2475 21.707 16.435L16.435 21.707C16.2475 21.8946 15.9932 21.9999 15.728 22Z"
fill={`url(#${gradientId})`}
mask={`url(#${maskId})`}
/>
</g>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M17.3974 8.39243C17.6596 8.65461 17.6596 9.0797 17.3974 9.34187L11.1314 15.6078C11.0055 15.7338 10.8347 15.8045 10.6567 15.8045C10.4787 15.8045 10.3079 15.7338 10.182 15.6078L6.60142 12.0273C6.33924 11.7651 6.33924 11.3401 6.60142 11.0779C6.8636 10.8157 7.28868 10.8157 7.55086 11.0779L10.6567 14.1837L16.448 8.39243C16.7102 8.13026 17.1352 8.13026 17.3974 8.39243Z"
fill="#FAFAFA"
/>
</g>
<defs>
<filter
id="filter0_dd_1669_13491"
x="-3"
y="-2"
width="30"
height="30"
filterUnits="userSpaceOnUse"
color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1" />
<feGaussianBlur stdDeviation="1.5" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1669_13491" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology radius="2" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_1669_13491" />
<feOffset dy="1" />
<feGaussianBlur stdDeviation="1.5" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0" />
<feBlend mode="normal" in2="effect1_dropShadow_1669_13491" result="effect2_dropShadow_1669_13491" />
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_1669_13491" result="shape" />
</filter>
<clipPath id="bgblur_0_1669_13491_clip_path" transform="translate(3 2)">
<path d="M13.2121 2.57414C12.5853 1.80862 11.4146 1.80862 10.788 2.57414L9.90009 3.65856C9.83924 3.73288 9.73773 3.76009 9.64787 3.72614L8.33677 3.23092C7.41123 2.88134 6.39741 3.46667 6.23738 4.44301L6.0107 5.82606C5.99517 5.92086 5.92086 5.99516 5.82606 6.0107L4.44301 6.23738C3.46668 6.39741 2.88134 7.41122 3.23092 8.33676L3.72614 9.64787C3.76009 9.73773 3.73288 9.83924 3.65856 9.90009L2.57414 10.7879C1.80862 11.4147 1.80862 12.5854 2.57414 13.2121L3.65856 14.0999C3.73288 14.1608 3.76009 14.2623 3.72614 14.3522L3.23092 15.6633C2.88135 16.5888 3.46667 17.6026 4.44301 17.7627L5.82606 17.9893C5.92086 18.0049 5.99517 18.0792 6.0107 18.174L6.23738 19.557C6.39741 20.5333 7.41122 21.1186 8.33677 20.7691L9.64787 20.2739C9.73773 20.24 9.83924 20.2671 9.90009 20.3415L10.788 21.4259C11.4146 22.1914 12.5853 22.1914 13.2121 21.4259L14.0999 20.3415C14.1608 20.2671 14.2623 20.24 14.3521 20.2739L15.6633 20.7691C16.5888 21.1186 17.6027 20.5333 17.7626 19.557L17.9894 18.174C18.0049 18.0792 18.0791 18.0049 18.1739 17.9893L19.557 17.7627C20.5334 17.6026 21.1187 16.5888 20.7691 15.6633L20.2739 14.3522C20.2399 14.2623 20.2671 14.1608 20.3414 14.0999L21.4259 13.2121C22.1914 12.5854 22.1914 11.4147 21.4259 10.7879L20.3414 9.90009C20.2671 9.83924 20.2399 9.73773 20.2739 9.64787L20.7691 8.33676C21.1187 7.41122 20.5334 6.39741 19.557 6.23738L18.1739 6.0107C18.0791 5.99516 18.0049 5.92086 17.9894 5.82606L17.7626 4.44301C17.6027 3.46668 16.5888 2.88134 15.6633 3.23092L14.3521 3.72614C14.2623 3.76009 14.1608 3.73288 14.0999 3.65856L13.2121 2.57414Z" />
</clipPath>
<linearGradient id="paint0_linear_1669_13491" x1="12" y1="7.5" x2="12" y2="41.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#3CD45A" />
<stop offset="1" stop-color="white" />
</linearGradient>
</defs>
</svg>
)
</svg>
)
}
const SuccessIcon = ({ className, ...props }: SVGProps<SVGSVGElement>) => {
const id = useId().replace(/:/g, '')
const maskId = `mask_${id}`
const filterId = `filter_${id}`
const gradientId = `paint_${id}`
const blurClipId = `blur_clip_${id}`
const checkPathData =
'M17.3974 8.39243C17.6596 8.65461 17.6596 9.0797 17.3974 9.34187L11.1314 15.6078C11.0055 15.7338 10.8347 15.8045 10.6567 15.8045C10.4787 15.8045 10.3079 15.7338 10.182 15.6078L6.60142 12.0273C6.33924 11.7651 6.33924 11.3401 6.60142 11.0779C6.8636 10.8157 7.28868 10.8157 7.55086 11.0779L10.6567 14.1837L16.448 8.39243C16.7102 8.13026 17.1352 8.13026 17.3974 8.39243Z'
const polygonPathData =
'M13.2121 2.57414C12.5853 1.80862 11.4146 1.80862 10.788 2.57414L9.90009 3.65856C9.83924 3.73288 9.73773 3.76009 9.64787 3.72614L8.33677 3.23092C7.41123 2.88134 6.39741 3.46667 6.23738 4.44301L6.0107 5.82606C5.99517 5.92086 5.92086 5.99516 5.82606 6.0107L4.44301 6.23738C3.46668 6.39741 2.88134 7.41122 3.23092 8.33676L3.72614 9.64787C3.76009 9.73773 3.73288 9.83924 3.65856 9.90009L2.57414 10.7879C1.80862 11.4147 1.80862 12.5854 2.57414 13.2121L3.65856 14.0999C3.73288 14.1608 3.76009 14.2623 3.72614 14.3522L3.23092 15.6633C2.88135 16.5888 3.46667 17.6026 4.44301 17.7627L5.82606 17.9893C5.92086 18.0049 5.99517 18.0792 6.0107 18.174L6.23738 19.557C6.39741 20.5333 7.41122 21.1186 8.33677 20.7691L9.64787 20.2739C9.73773 20.24 9.83924 20.2671 9.90009 20.3415L10.788 21.4259C11.4146 22.1914 12.5853 22.1914 13.2121 21.4259L14.0999 20.3415C14.1608 20.2671 14.2623 20.24 14.3521 20.2739L15.6633 20.7691C16.5888 21.1186 17.6027 20.5333 17.7626 19.557L17.9894 18.174C18.0049 18.0792 18.0791 18.0049 18.1739 17.9893L19.557 17.7627C20.5334 17.6026 21.1187 16.5888 20.7691 15.6633L20.2739 14.3522C20.2399 14.2623 20.2671 14.1608 20.3414 14.0999L21.4259 13.2121C22.1914 12.5854 22.1914 11.4147 21.4259 10.7879L20.3414 9.90009C20.2671 9.83924 20.2399 9.73773 20.2739 9.64787L20.7691 8.33676C21.1187 7.41122 20.5334 6.39741 19.557 6.23738L18.1739 6.0107C18.0791 5.99516 18.0791 5.92086 17.9894 5.82606L17.7626 4.44301C17.6027 3.46668 16.5888 2.88134 15.6633 3.23092L14.3521 3.72614C14.2623 3.76009 14.1608 3.73288 14.0999 3.65856L13.2121 2.57414Z'
return (
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={className} {...props}>
<defs>
<mask
id={maskId}
style={{ maskType: 'luminance' }}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="24"
height="24">
{/* Show white part */}
<rect width="24" height="24" fill="white" />
{/* Clip black part */}
<path d={checkPathData} fill="black" />
</mask>
<clipPath id={blurClipId} transform="translate(3 2)">
<path d={polygonPathData} />
</clipPath>
<filter
id={filterId}
x="-3"
y="-2"
width="30"
height="30"
filterUnits="userSpaceOnUse"
colorInterpolationFilters="sRGB">
<feFlood floodOpacity="0" result="BackgroundImageFix" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feOffset dy="1" />
<feGaussianBlur stdDeviation="1.5" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0" />
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow" />
<feColorMatrix
in="SourceAlpha"
type="matrix"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
result="hardAlpha"
/>
<feMorphology radius="2" operator="dilate" in="SourceAlpha" result="effect2_dropShadow" />
<feOffset dy="1" />
<feGaussianBlur stdDeviation="1.5" />
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.02 0" />
<feBlend mode="normal" in2="effect1_dropShadow" result="effect2_dropShadow" />
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow" result="shape" />
</filter>
<linearGradient id={gradientId} x1="12" y1="7.5" x2="12" y2="41.5" gradientUnits="userSpaceOnUse">
<stop stopColor="#3CD45A" />
<stop offset="1" stopColor="white" />
</linearGradient>
</defs>
<g mask={`url(#${maskId})`}>
<foreignObject x="-3" y="-2" width="30" height="30">
<div
style={{
backdropFilter: 'blur(2px)',
clipPath: `url(#${blurClipId})`,
height: '100%',
width: '100%'
}}
/>
</foreignObject>
<g filter={`url(#${filterId})`}>
<path d={polygonPathData} fill={`url(#${gradientId})`} />
</g>
</g>
</svg>
)
}
const ErrorIcon = ({ className }: SVGProps<SVGSVGElement>) => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}>
@ -267,83 +292,137 @@ const ErrorIcon = ({ className }: SVGProps<SVGSVGElement>) => (
</svg>
)
const CloseIcon = ({ className }: SVGProps<SVGSVGElement>) => (
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className={className}>
<path
fill-rule="evenodd"
clip-rule="evenodd"
d="M15.4419 5.44194C15.686 5.19786 15.686 4.80214 15.4419 4.55806C15.1979 4.31398 14.8021 4.31398 14.5581 4.55806L10 9.11612L5.44194 4.55806C5.19786 4.31398 4.80214 4.31398 4.55806 4.55806C4.31398 4.80214 4.31398 5.19786 4.55806 5.44194L9.11612 10L4.55806 14.5581C4.31398 14.8021 4.31398 15.1979 4.55806 15.4419C4.80214 15.686 5.19786 15.686 5.44194 15.4419L10 10.8839L14.5581 15.4419C14.8021 15.686 15.1979 15.686 15.4419 15.4419C15.686 15.1979 15.686 14.8021 15.4419 14.5581L10.8839 10L15.4419 5.44194Z"
fill="black"
fill-opacity="0.4"
/>
</svg>
)
interface ToastProps {
// const CloseIcon = ({ className }: SVGProps<SVGSVGElement>) => (
// <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className={className}>
// <path
// fill-rule="evenodd"
// clip-rule="evenodd"
// d="M15.4419 5.44194C15.686 5.19786 15.686 4.80214 15.4419 4.55806C15.1979 4.31398 14.8021 4.31398 14.5581 4.55806L10 9.11612L5.44194 4.55806C5.19786 4.31398 4.80214 4.31398 4.55806 4.55806C4.31398 4.80214 4.31398 5.19786 4.55806 5.44194L9.11612 10L4.55806 14.5581C4.31398 14.8021 4.31398 15.1979 4.55806 15.4419C4.80214 15.686 5.19786 15.686 5.44194 15.4419L10 10.8839L14.5581 15.4419C14.8021 15.686 15.1979 15.686 15.4419 15.4419C15.686 15.1979 15.686 14.8021 15.4419 14.5581L10.8839 10L15.4419 5.44194Z"
// fill="black"
// fill-opacity="0.4"
// />
// </svg>
// )
interface ToastProps<ToastData = unknown> {
id: string | number
type: 'info' | 'warning' | 'error' | 'success' | 'loading'
title: string
description?: string
coloredMessage?: string
coloredBackground?: boolean
type?: 'info' | 'warning' | 'error' | 'success' | 'loading' | 'custom'
title: ReactNode
description?: ReactNode
colored?: boolean
duration?: number
dismissable?: boolean
onDismiss?: () => void
button?: {
icon?: ReactNode
label: string
onClick: () => void
}
link?: {
label: string
href?: string
onClick?: () => void
}
promise?: Promise<unknown>
button?: Action | ReactNode
promise?: Promise<ToastData>
classNames?: ToastClassnames
jsx?: (id: number | string) => React.ReactElement
}
function toast(props: Omit<ToastProps, 'id'>) {
return sonnerToast.custom((id) => <Toast id={id} {...props} />, {
classNames: { toast: props.coloredBackground ? 'backdrop-blur-md rounded-xs' : undefined }
})
const type = props.type ?? 'info'
const baseClassNames: ToastClassnames = {
toast: cn(
'flex rounded-xs p-4 bg-background border-border border-[0.5px] shadow-lg items-center',
props.button ? 'gap-3' : 'gap-4',
props.colored && type !== 'custom' && toastBgColorVariants({ type }),
props.classNames?.toast
),
content: cn('flex flex-col', props.description && (props.button ? 'gap-1' : 'gap-2')),
title: cn(
'text-md font-medium leading-4.5',
props.description === undefined && 'text-xs leading-3.5 tracking-normal',
props.classNames?.title
),
description: cn('text-foreground-secondary text-xs leading-3.5 tracking-normal', props.classNames?.description),
actionButton: cn(
'py-1 px-2 rounded-3xs flex items-center h-7 max-h-7 bg-background-subtle border-[0.5px] border-border',
'text-foreground text-sm leading-4 tracking-normal min-w-fit',
props.colored && 'bg-white/10',
props.classNames?.actionButton
),
icon: cn('size-6 min-w-6', props.description && 'self-start'),
loader: cn('!static ![--size:24px]')
}
const { classNames: externalClassNames, ...rest } = props
delete externalClassNames?.toast
const classNames = merge(baseClassNames, externalClassNames)
const data = {
classNames,
unstyled: true,
description: rest.description,
duration: rest.duration,
action: rest.button,
dismissible: rest.dismissable,
onDismiss: rest.onDismiss
} satisfies ExternalToast
switch (type) {
case 'info':
return sonnerToast.info(props.title, data)
case 'warning':
return sonnerToast.warning(props.title, data)
case 'error':
return sonnerToast.error(props.title, data)
case 'success':
return sonnerToast.success(props.title, data)
case 'loading':
const id = sonnerToast.loading(props.title, data)
if (props.promise) {
// Auto dismiss when promise is settled
props.promise.finally(() => {
sonnerToast.dismiss(id)
})
}
return id
default:
console.warn('Using custom toast without a jsx.')
return sonnerToast.custom(props.jsx ?? ((id) => <div id={String(id)}>{props.title}</div>))
}
}
interface QuickApiProps extends Omit<ToastProps, 'type' | 'id'> {}
interface QuickApiProps extends Omit<ToastProps, 'type' | 'id' | 'title'> {}
interface QuickLoadingProps extends QuickApiProps {
promise: ToastProps['promise']
}
toast.info = (props: QuickApiProps) => {
toast.info = (message: ReactNode, data?: QuickApiProps) => {
toast({
type: 'info',
...props
title: message,
...data
})
}
toast.success = (props: QuickApiProps) => {
toast.success = (message: ReactNode, data?: QuickApiProps) => {
toast({
type: 'success',
...props
title: message,
...data
})
}
toast.warning = (props: QuickApiProps) => {
toast.warning = (message: ReactNode, data?: QuickApiProps) => {
toast({
type: 'warning',
...props
title: message,
...data
})
}
toast.error = (props: QuickApiProps) => {
toast.error = (message: ReactNode, data?: QuickApiProps) => {
toast({
type: 'error',
...props
title: message,
...data
})
}
toast.loading = (props: QuickLoadingProps) => {
toast.loading = (message: ReactNode, data: QuickLoadingProps) => {
toast({
type: 'loading',
...props
title: message,
...data
})
}
@ -351,125 +430,136 @@ toast.dismiss = (id: ToastProps['id']) => {
sonnerToast.dismiss(id)
}
const toastColorVariants = cva(undefined, {
variants: {
type: {
info: 'text-blue-500',
warning: 'text-warning-base',
error: 'text-error-base',
success: 'text-success-base',
loading: 'text-foreground-muted'
}
}
})
// const toastColorVariants = cva(undefined, {
// variants: {
// type: {
// info: 'text-blue-500',
// warning: 'text-warning-base',
// error: 'text-error-base',
// success: 'text-success-base',
// loading: 'text-foreground-muted'
// }
// }
// })
const toastBgColorVariants = cva(undefined, {
variants: {
type: {
info: 'bg-blue-500/10 border-blue-500/20',
warning: 'bg-orange-500/10 border-orange-500/20',
error: 'bg-red-500/10 border-red-500/20',
success: 'bg-primary/10 border-primary/20',
info: 'backdrop-blur-md bg-blue-500/10 border-blue-500/20',
warning: 'backdrop-blur-md bg-orange-500/10 border-orange-500/20',
error: 'backdrop-blur-md bg-red-500/10 border-red-500/20',
success: 'backdrop-blur-md bg-primary/10 border-primary/20',
loading: 'backdrop-blur-none'
}
}
})
function Toast({
id,
type,
title,
description,
coloredMessage,
coloredBackground,
dismissable,
onDismiss,
button,
link
}: ToastProps) {
const icon = useMemo(() => {
switch (type) {
case 'info':
return <InfoIcon className="size-6" />
case 'error':
return <ErrorIcon className="size-6" />
case 'loading':
return <Loader2Icon className="size-6 animate-spin" />
case 'success':
return <SuccessIcon className="size-6" />
case 'warning':
return <WarningIcon className="size-6" />
}
}, [type])
// function Toast({
// id,
// type,
// title,
// description,
// coloredMessage,
// colored: coloredBackground,
// dismissable,
// onDismiss,
// button,
// link
// }: ToastProps) {
// const icon = useMemo(() => {
// switch (type) {
// case 'info':
// return <InfoIcon className="size-6" />
// case 'error':
// return <ErrorIcon className="size-6" />
// case 'loading':
// return <Loader2Icon className="size-6 animate-spin" />
// case 'success':
// return <SuccessIcon className="size-6" />
// case 'warning':
// return <WarningIcon className="size-6" />
// }
// }, [type])
const handleDismiss = useCallback(() => {
sonnerToast.dismiss(id)
onDismiss?.()
}, [id, onDismiss])
// const handleDismiss = useCallback(() => {
// sonnerToast.dismiss(id)
// onDismiss?.()
// }, [id, onDismiss])
// return (
// <div
// id={String(id)}
// className={cn(
// 'flex p-4 rounded-xs bg-background border-border border-[0.5px] items-center shadow-lg',
// coloredBackground && toastBgColorVariants({ type })
// )}
// aria-label="Toast">
// {dismissable && (
// <button type="button" aria-label="Dismiss the toast" onClick={handleDismiss}>
// <CloseIcon className="size-5 absolute top-[5px] right-1.5" />
// </button>
// )}
// <div className={cn('flex items-start flex-1', button !== undefined ? 'gap-3' : 'gap-4')}>
// {icon}
// <div className="cs-toast-content flex flex-col gap-1">
// <div className="cs-toast-title font-medium leading-4.5" role="heading">
// {title}
// </div>
// <div className="cs-toast-description">
// <p className="text-foreground-secondary text-xs leading-3.5 tracking-normal">
// {coloredMessage && <span className={toastColorVariants({ type })}>{coloredMessage} </span>}
// {description}
// </p>
// </div>
// {link && (
// // FIXME: missing typography/typography components/p/letter-spacing
// <div className="cs-toast-link text-foreground-muted text-xs leading-3.5 tracking-normal">
// <a
// href={link.href}
// onClick={link.onClick}
// className={cn(
// 'underline decoration-foreground-muted cursor-pointer',
// 'hover:text-foreground-secondary',
// // FIXME: missing active style in design
// 'active:text-black'
// )}>
// {link.label}
// </a>
// </div>
// )}
// </div>
// </div>
// {button !== undefined && (
// <button
// type="button"
// // FIXME: missing hover/active style
// className={cn(
// 'py-1 px-2 rounded-3xs flex items-center h-7 bg-background-subtle border-[0.5px] border-border',
// 'text-foreground text-sm leading-4 tracking-normal',
// button.icon !== undefined && 'gap-2'
// )}
// onClick={button.onClick}>
// <div>{button.icon}</div>
// <div>{button.label}</div>
// </button>
// )}
// </div>
// )
// }
const Toaster = ({ ...props }: ToasterProps) => {
return (
<div
id={String(id)}
className={cn(
'flex p-4 rounded-xs bg-background border-border border-[0.5px] items-center shadow-lg',
coloredBackground && toastBgColorVariants({ type })
)}
aria-label="Toast">
{dismissable && (
<button type="button" aria-label="Dismiss the toast" onClick={handleDismiss}>
<CloseIcon className="size-5 absolute top-[5px] right-1.5" />
</button>
)}
<div className={cn('flex items-start flex-1', button !== undefined ? 'gap-3' : 'gap-4')}>
{icon}
<div className="cs-toast-content flex flex-col gap-1">
<div className="cs-toast-title font-medium leading-4.5" role="heading">
{title}
</div>
<div className="cs-toast-description">
<p className="text-foreground-secondary text-xs leading-3.5 tracking-normal">
{coloredMessage && <span className={toastColorVariants({ type })}>{coloredMessage} </span>}
{description}
</p>
</div>
{link && (
// FIXME: missing typography/typography components/p/letter-spacing
<div className="cs-toast-link text-foreground-muted text-xs leading-3.5 tracking-normal">
<a
href={link.href}
onClick={link.onClick}
className={cn(
'underline decoration-foreground-muted cursor-pointer',
'hover:text-foreground-secondary',
// FIXME: missing active style in design
'active:text-black'
)}>
{link.label}
</a>
</div>
)}
</div>
</div>
{button !== undefined && (
<button
type="button"
// FIXME: missing hover/active style
className={cn(
'py-1 px-2 rounded-3xs flex items-center h-7 bg-background-subtle border-[0.5px] border-border',
'text-foreground text-sm leading-4 tracking-normal',
button.icon !== undefined && 'gap-2'
)}
onClick={button.onClick}>
<div>{button.icon}</div>
<div>{button.label}</div>
</button>
)}
</div>
<Sonner
className="toaster group"
icons={{
info: <InfoIcon />,
success: <SuccessIcon />,
warning: <WarningIcon />,
error: <ErrorIcon />
}}
{...props}
/>
)
}
const Toaster = ({ ...props }: ToasterProps) => {
return <Sonner className="toaster group" {...props} />
}
export { toast, Toaster }

View File

@ -1,7 +1,16 @@
import { Button } from '@cherrystudio/ui'
import { toast, Toaster } from '@cherrystudio/ui'
import type { Meta, StoryObj } from '@storybook/react'
import { RefreshCwIcon } from 'lucide-react'
interface PlaygroundArgs {
type: 'info' | 'success' | 'warning' | 'error' | 'loading'
title: string
description: string
colored: boolean
duration: number
withButton: boolean
buttonLabel: string
}
const meta: Meta<typeof Toaster> = {
title: 'Components/Primitives/Sonner',
@ -29,14 +38,113 @@ const meta: Meta<typeof Toaster> = {
export default meta
type Story = StoryObj<typeof meta>
// Playground
export const Playground: StoryObj<PlaygroundArgs> = {
args: {
type: 'info',
title: 'Notification Title',
description: 'This is a description that provides more details about the notification.',
colored: false,
duration: 4000,
withButton: false,
buttonLabel: 'Action'
},
argTypes: {
type: {
control: 'select',
options: ['info', 'success', 'warning', 'error', 'loading'],
description: 'Type of toast notification'
},
title: {
control: 'text',
description: 'Main message of the toast'
},
description: {
control: 'text',
description: 'Optional detailed description'
},
colored: {
control: 'boolean',
description: 'Enable colored background'
},
duration: {
control: { type: 'number', min: 1000, max: 10000, step: 1000 },
description: 'Duration in milliseconds (use Infinity for persistent)'
},
withButton: {
control: 'boolean',
description: 'Show action button'
},
buttonLabel: {
control: 'text',
description: 'Label for the action button',
if: { arg: 'withButton', truthy: true }
}
},
render: (args: PlaygroundArgs) => {
const handleToast = () => {
const toastOptions: {
description?: string
colored: boolean
duration: number
button?: {
label: string
onClick: () => void
}
promise?: Promise<void>
} = {
description: args.description || undefined,
colored: args.colored,
duration: args.duration
}
if (args.withButton) {
toastOptions.button = {
label: args.buttonLabel || 'Action',
onClick: () => toast.info('Button clicked!')
}
}
switch (args.type) {
case 'info':
toast.info(args.title, toastOptions)
break
case 'success':
toast.success(args.title, toastOptions)
break
case 'warning':
toast.warning(args.title, toastOptions)
break
case 'error':
toast.error(args.title, toastOptions)
break
case 'loading':
toast.loading(args.title, {
...toastOptions,
promise: new Promise<void>((resolve) => setTimeout(resolve, 2000))
})
break
}
}
return (
<div className="flex flex-col gap-3">
<Button onClick={handleToast}>Show Toast</Button>
<div className="text-sm text-muted-foreground max-w-md">
Use the controls panel below to customize the toast properties and click the button to preview.
</div>
</div>
)
}
}
// Basic Toast Types
export const Info: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.info({
title: 'Information',
toast.info('Information', {
description: 'This is an informational message.'
})
}>
@ -51,8 +159,7 @@ export const Success: Story = {
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.success({
title: 'Success!',
toast.success('Success!', {
description: 'Operation completed successfully.'
})
}>
@ -67,8 +174,7 @@ export const ErrorToast: Story = {
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.error({
title: 'Error',
toast.error('Error', {
description: 'Something went wrong. Please try again.'
})
}>
@ -83,8 +189,7 @@ export const Warning: Story = {
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.warning({
title: 'Warning',
toast.warning('Warning', {
description: 'Please be careful with this action.'
})
}>
@ -104,8 +209,7 @@ export const Loading: Story = {
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.loading({
title: 'Loading...',
toast.loading('Loading...', {
description: 'Please wait while we process your request.',
promise: mockPromise
})
@ -121,14 +225,13 @@ export const Loading: Story = {
export const AllTypes: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Button onClick={() => toast.info({ title: 'Info Toast' })}>Info</Button>
<Button onClick={() => toast.success({ title: 'Success Toast' })}>Success</Button>
<Button onClick={() => toast.warning({ title: 'Warning Toast' })}>Warning</Button>
<Button onClick={() => toast.error({ title: 'Error Toast' })}>Error</Button>
<Button onClick={() => toast.info('Info Toast')}>Info</Button>
<Button onClick={() => toast.success('Success Toast')}>Success</Button>
<Button onClick={() => toast.warning('Warning Toast')}>Warning</Button>
<Button onClick={() => toast.error('Error Toast')}>Error</Button>
<Button
onClick={() =>
toast.loading({
title: 'Loading Toast',
toast.loading('Loading Toast', {
promise: new Promise((resolve) => setTimeout(resolve, 2000))
})
}>
@ -144,8 +247,7 @@ export const WithDescription: Story = {
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.success({
title: 'Event Created',
toast.success('Event Created', {
description: 'Your event has been created successfully. You can now share it with others.'
})
}>
@ -155,29 +257,88 @@ export const WithDescription: Story = {
)
}
// With Colored Message
export const WithColoredMessage: Story = {
// With Custom Duration
export const WithCustomDuration: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
toast.info({
title: 'System Update',
coloredMessage: 'New version available!',
description: 'Click the button to update now.'
toast.info('Quick message', {
description: 'This will disappear in 1 second',
duration: 1000
})
}>
Info with Colored Message
1 Second
</Button>
<Button
onClick={() =>
toast.warning({
title: 'Disk Space Low',
coloredMessage: '95% used',
description: 'Please free up some space.'
toast.success('Normal duration', {
description: 'This uses default duration (4 seconds)'
})
}>
Warning with Colored Message
Default (4s)
</Button>
<Button
onClick={() =>
toast.warning('Important message', {
description: 'This will stay for 10 seconds',
duration: 10000
})
}>
10 Seconds
</Button>
<Button
onClick={() =>
toast.info('Persistent message', {
description: 'This will stay until manually dismissed',
duration: Number.POSITIVE_INFINITY
})
}>
Infinite
</Button>
</div>
)
}
// With Action Button
export const WithActionButton: Story = {
render: () => (
<div className="flex flex-col gap-2">
<Button
onClick={() =>
toast.success('Changes Saved', {
description: 'Your changes have been saved successfully.',
button: {
label: 'Undo',
onClick: () => toast.info('Undoing changes...')
}
})
}>
Success with Action
</Button>
<Button
onClick={() =>
toast.error('Update Failed', {
description: 'Failed to update the record.',
button: {
label: 'Retry',
onClick: () => toast.info('Retrying...')
}
})
}>
Error with Action
</Button>
<Button
onClick={() =>
toast.info('Update Available', {
description: 'A new version is ready to install.',
button: {
label: 'Update',
onClick: () => toast.info('Starting update...')
}
})
}>
Info with Action
</Button>
</div>
)
@ -189,40 +350,36 @@ export const WithColoredBackground: Story = {
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
toast.info({
title: 'Information',
toast.info('Information', {
description: 'This toast has a colored background.',
coloredBackground: true
colored: true
})
}>
Info Background
</Button>
<Button
onClick={() =>
toast.success({
title: 'Success!',
toast.success('Success!', {
description: 'This toast has a colored background.',
coloredBackground: true
colored: true
})
}>
Success Background
</Button>
<Button
onClick={() =>
toast.warning({
title: 'Warning',
toast.warning('Warning', {
description: 'This toast has a colored background.',
coloredBackground: true
colored: true
})
}>
Warning Background
</Button>
<Button
onClick={() =>
toast.error({
title: 'Error',
toast.error('Error', {
description: 'This toast has a colored background.',
coloredBackground: true
colored: true
})
}>
Error Background
@ -231,19 +388,18 @@ export const WithColoredBackground: Story = {
)
}
// Colored Background with Actions
export const ColoredBackgroundWithActions: Story = {
// Colored Background with Action
export const ColoredBackgroundWithAction: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
toast.success({
title: 'File Uploaded',
toast.success('File Uploaded', {
description: 'Your file has been uploaded successfully.',
coloredBackground: true,
colored: true,
button: {
label: 'View',
onClick: () => toast.info({ title: 'Opening file...' })
onClick: () => toast.info('Opening file...')
}
})
}>
@ -251,138 +407,29 @@ export const ColoredBackgroundWithActions: Story = {
</Button>
<Button
onClick={() =>
toast.warning({
title: 'Action Required',
toast.warning('Action Required', {
description: 'Please review the changes.',
coloredBackground: true,
link: {
colored: true,
button: {
label: 'Review',
onClick: () => toast.info({ title: 'Opening review...' })
onClick: () => toast.info('Opening review...')
}
})
}>
Warning with Link
Warning with Button
</Button>
<Button
onClick={() =>
toast.error({
title: 'Update Failed',
toast.error('Update Failed', {
description: 'Failed to update the record.',
coloredBackground: true,
colored: true,
button: {
icon: <RefreshCwIcon className="h-4 w-4" />,
label: 'Retry',
onClick: () => toast.info({ title: 'Retrying...' })
},
link: {
label: 'Learn More',
onClick: () => toast.info({ title: 'Opening help...' })
onClick: () => toast.info('Retrying...')
}
})
}>
Error with Button & Link
</Button>
</div>
)
}
// With Action Button
export const WithActionButton: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.success({
title: 'Changes Saved',
description: 'Your changes have been saved successfully.',
button: {
icon: <RefreshCwIcon className="h-4 w-4" />,
label: 'Undo',
onClick: () => toast.info({ title: 'Undoing changes...' })
}
})
}>
Show Toast with Action Button
</Button>
</div>
)
}
// With Link
export const WithLink: Story = {
render: () => (
<div className="flex flex-wrap gap-2">
<Button
onClick={() =>
toast.info({
title: 'Update Available',
description: 'A new version is ready to install.',
link: {
label: 'View Details',
onClick: () => toast.info({ title: 'Opening details...' })
}
})
}>
Toast with Click Handler
</Button>
<Button
onClick={() =>
toast.success({
title: 'Documentation Updated',
description: 'Check out the new features.',
link: {
label: 'Read More',
href: 'https://example.com',
onClick: () => console.log('Link clicked')
}
})
}>
Toast with Link
</Button>
</div>
)
}
// With Button and Link
export const WithButtonAndLink: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.warning({
title: 'Action Required',
description: 'Please review the changes before proceeding.',
button: {
icon: <RefreshCwIcon className="h-4 w-4" />,
label: 'Review',
onClick: () => toast.info({ title: 'Opening review...' })
},
link: {
label: 'Learn More',
onClick: () => toast.info({ title: 'Opening documentation...' })
}
})
}>
Show Toast with Button and Link
</Button>
</div>
)
}
// Dismissable Toast
export const DismissableToast: Story = {
render: () => (
<div className="flex flex-col gap-3">
<Button
onClick={() =>
toast.info({
title: 'Dismissable Toast',
description: 'You can close this toast by clicking the X button.',
dismissable: true,
onDismiss: () => console.log('Toast dismissed')
})
}>
Show Dismissable Toast
Error with Button
</Button>
</div>
)
@ -392,10 +439,10 @@ export const DismissableToast: Story = {
export const MultipleToasts: Story = {
render: () => {
const showMultiple = () => {
toast.success({ title: 'First notification', description: 'This is the first message' })
setTimeout(() => toast.info({ title: 'Second notification', description: 'This is the second message' }), 100)
setTimeout(() => toast.warning({ title: 'Third notification', description: 'This is the third message' }), 200)
setTimeout(() => toast.error({ title: 'Fourth notification', description: 'This is the fourth message' }), 300)
toast.success('First notification', { description: 'This is the first message' })
setTimeout(() => toast.info('Second notification', { description: 'This is the second message' }), 100)
setTimeout(() => toast.warning('Third notification', { description: 'This is the third message' }), 200)
setTimeout(() => toast.error('Fourth notification', { description: 'This is the fourth message' }), 300)
}
return (
@ -416,8 +463,7 @@ export const PromiseExample: Story = {
}, 2000)
})
toast.loading({
title: 'Fetching data...',
toast.loading('Fetching data...', {
description: 'Please wait while we load your information.',
promise
})
@ -436,60 +482,54 @@ export const RealWorldExamples: Story = {
render: () => {
const handleFileSave = () => {
const promise = new Promise((resolve) => setTimeout(resolve, 1500))
toast.loading({
title: 'Saving file...',
toast.loading('Saving file...', {
promise
})
promise.then(() => {
toast.success({
title: 'File saved',
description: 'Your file has been saved successfully.'
toast.success('File saved', {
description: 'Your file has been saved successfully.',
button: {
label: 'View',
onClick: () => toast.info('Opening file...')
}
})
})
}
const handleFormSubmit = () => {
toast.success({
title: 'Form submitted',
toast.success('Form submitted', {
description: 'Your changes have been saved successfully.',
button: {
label: 'View',
onClick: () => toast.info({ title: 'Opening form...' })
label: 'Undo',
onClick: () => toast.info('Undoing changes...')
}
})
}
const handleDelete = () => {
toast.error({
title: 'Failed to delete',
toast.error('Failed to delete', {
description: 'You do not have permission to delete this item.',
button: {
icon: <RefreshCwIcon className="h-4 w-4" />,
label: 'Retry',
onClick: () => toast.info({ title: 'Retrying...' })
onClick: () => toast.info('Retrying...')
}
})
}
const handleCopy = () => {
navigator.clipboard.writeText('https://example.com')
toast.success({
title: 'Copied to clipboard',
toast.success('Copied to clipboard', {
description: 'The link has been copied to your clipboard.'
})
}
const handleUpdate = () => {
toast.info({
title: 'Update available',
toast.info('Update available', {
description: 'A new version of the application is ready to install.',
colored: true,
button: {
label: 'Update Now',
onClick: () => toast.info({ title: 'Starting update...' })
},
link: {
label: 'Release Notes',
onClick: () => toast.info({ title: 'Opening release notes...' })
onClick: () => toast.info('Starting update...')
}
})
}