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 { cn } from '@cherrystudio/ui/utils'
import { cva } from 'class-variance-authority' import { cva } from 'class-variance-authority'
import { Loader2Icon } from 'lucide-react' import { merge } from 'lodash'
import { type ReactNode, type SVGProps, useCallback, useMemo } from 'react' 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' import { toast as sonnerToast, Toaster as Sonner, type ToasterProps } from 'sonner'
const InfoIcon = ({ className }: SVGProps<SVGSVGElement>) => ( const InfoIcon = ({ className }: SVGProps<SVGSVGElement>) => (
@ -70,135 +71,159 @@ const InfoIcon = ({ className }: SVGProps<SVGSVGElement>) => (
</svg> </svg>
) )
const WarningIcon = ({ className }: SVGProps<SVGSVGElement>) => ( const WarningIcon = ({ className, ...props }: SVGProps<SVGSVGElement>) => {
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={className}> // Remove colons to prevent ID recognition issues in some CSS environments
<g filter="url(#filter0_dd_1669_13487)"> const id = useId().replace(/:/g, '')
<path const filterId = `filter_${id}`
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" const maskId = `mask_${id}`
fill="url(#paint0_linear_1669_13487)" const gradientId = `paint_${id}`
/> return (
<path <svg
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" width="24"
fill="#FAFAFA" height="24"
/> viewBox="0 0 24 24"
<path fill="none"
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" xmlns="http://www.w3.org/2000/svg"
fill="#FAFAFA" className={className}
/> {...props}>
</g> <defs>
<defs> <mask id={maskId}>
<filter {/* Show white part */}
id="filter0_dd_1669_13487" <rect width="24" height="24" fill="white" />
x="-3" {/* Clip black part */}
y="-2" <g fill="black">
width="30" <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" />
height="30" <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" />
filterUnits="userSpaceOnUse" </g>
color-interpolation-filters="sRGB"> </mask>
<feFlood flood-opacity="0" result="BackgroundImageFix" /> <filter
<feColorMatrix id={filterId}
in="SourceAlpha" x="-3"
type="matrix" y="-2"
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" width="30"
result="hardAlpha" height="30"
/> filterUnits="userSpaceOnUse"
<feOffset dy="1" /> colorInterpolationFilters="sRGB">
<feGaussianBlur stdDeviation="1.5" /> <feFlood floodOpacity="0" result="BackgroundImageFix" />
<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" /> <feColorMatrix
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1669_13487" /> in="SourceAlpha"
<feColorMatrix type="matrix"
in="SourceAlpha" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0"
type="matrix" result="hardAlpha"
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" />
<feMorphology radius="2" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_1669_13487" /> <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" />
<feOffset dy="1" /> <feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow" />
<feGaussianBlur stdDeviation="1.5" /> <feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow" result="shape" />
<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" /> </filter>
<feBlend mode="normal" in2="effect1_dropShadow_1669_13487" result="effect2_dropShadow_1669_13487" /> <linearGradient id={gradientId} x1="12" y1="12" x2="12" y2="30.5" gradientUnits="userSpaceOnUse">
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_1669_13487" result="shape" /> <stop stopColor="#F59E0B" />
</filter> <stop offset="1" stopColor="white" />
<linearGradient id="paint0_linear_1669_13487" x1="12" y1="12" x2="12" y2="30.5" gradientUnits="userSpaceOnUse"> </linearGradient>
<stop stop-color="#F59E0B" /> </defs>
<stop offset="1" stop-color="white" /> <g filter={`url(#${filterId})`}>
</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">
<path <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" 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_13491)" fill={`url(#${gradientId})`}
mask={`url(#${maskId})`}
/> />
</g> </g>
<path </svg>
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" const SuccessIcon = ({ className, ...props }: SVGProps<SVGSVGElement>) => {
/> const id = useId().replace(/:/g, '')
</g> const maskId = `mask_${id}`
<defs> const filterId = `filter_${id}`
<filter const gradientId = `paint_${id}`
id="filter0_dd_1669_13491" const blurClipId = `blur_clip_${id}`
x="-3"
y="-2" const checkPathData =
width="30" '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'
height="30"
filterUnits="userSpaceOnUse" const polygonPathData =
color-interpolation-filters="sRGB"> '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'
<feFlood flood-opacity="0" result="BackgroundImageFix" />
<feColorMatrix return (
in="SourceAlpha" <svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" className={className} {...props}>
type="matrix" <defs>
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" <mask
result="hardAlpha" id={maskId}
/> style={{ maskType: 'luminance' }}
<feOffset dy="1" /> maskUnits="userSpaceOnUse"
<feGaussianBlur stdDeviation="1.5" /> x="0"
<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" /> y="0"
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_1669_13491" /> width="24"
<feColorMatrix height="24">
in="SourceAlpha" {/* Show white part */}
type="matrix" <rect width="24" height="24" fill="white" />
values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" {/* Clip black part */}
result="hardAlpha" <path d={checkPathData} fill="black" />
/> </mask>
<feMorphology radius="2" operator="dilate" in="SourceAlpha" result="effect2_dropShadow_1669_13491" />
<feOffset dy="1" /> <clipPath id={blurClipId} transform="translate(3 2)">
<feGaussianBlur stdDeviation="1.5" /> <path d={polygonPathData} />
<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" /> </clipPath>
<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
</filter> id={filterId}
<clipPath id="bgblur_0_1669_13491_clip_path" transform="translate(3 2)"> x="-3"
<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" /> y="-2"
</clipPath> width="30"
<linearGradient id="paint0_linear_1669_13491" x1="12" y1="7.5" x2="12" y2="41.5" gradientUnits="userSpaceOnUse"> height="30"
<stop stop-color="#3CD45A" /> filterUnits="userSpaceOnUse"
<stop offset="1" stop-color="white" /> colorInterpolationFilters="sRGB">
</linearGradient> <feFlood floodOpacity="0" result="BackgroundImageFix" />
</defs> <feColorMatrix
</svg> 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>) => ( 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}> <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> </svg>
) )
const CloseIcon = ({ className }: SVGProps<SVGSVGElement>) => ( // 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}> // <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 20 20" fill="none" className={className}>
<path // <path
fill-rule="evenodd" // fill-rule="evenodd"
clip-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" // 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="black"
fill-opacity="0.4" // fill-opacity="0.4"
/> // />
</svg> // </svg>
) // )
interface ToastProps { interface ToastProps<ToastData = unknown> {
id: string | number id: string | number
type: 'info' | 'warning' | 'error' | 'success' | 'loading' type?: 'info' | 'warning' | 'error' | 'success' | 'loading' | 'custom'
title: string title: ReactNode
description?: string description?: ReactNode
coloredMessage?: string colored?: boolean
coloredBackground?: boolean duration?: number
dismissable?: boolean dismissable?: boolean
onDismiss?: () => void onDismiss?: () => void
button?: { button?: Action | ReactNode
icon?: ReactNode promise?: Promise<ToastData>
label: string classNames?: ToastClassnames
onClick: () => void jsx?: (id: number | string) => React.ReactElement
}
link?: {
label: string
href?: string
onClick?: () => void
}
promise?: Promise<unknown>
} }
function toast(props: Omit<ToastProps, 'id'>) { function toast(props: Omit<ToastProps, 'id'>) {
return sonnerToast.custom((id) => <Toast id={id} {...props} />, { const type = props.type ?? 'info'
classNames: { toast: props.coloredBackground ? 'backdrop-blur-md rounded-xs' : undefined }
}) 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 { interface QuickLoadingProps extends QuickApiProps {
promise: ToastProps['promise'] promise: ToastProps['promise']
} }
toast.info = (props: QuickApiProps) => { toast.info = (message: ReactNode, data?: QuickApiProps) => {
toast({ toast({
type: 'info', type: 'info',
...props title: message,
...data
}) })
} }
toast.success = (props: QuickApiProps) => { toast.success = (message: ReactNode, data?: QuickApiProps) => {
toast({ toast({
type: 'success', type: 'success',
...props title: message,
...data
}) })
} }
toast.warning = (props: QuickApiProps) => { toast.warning = (message: ReactNode, data?: QuickApiProps) => {
toast({ toast({
type: 'warning', type: 'warning',
...props title: message,
...data
}) })
} }
toast.error = (props: QuickApiProps) => { toast.error = (message: ReactNode, data?: QuickApiProps) => {
toast({ toast({
type: 'error', type: 'error',
...props title: message,
...data
}) })
} }
toast.loading = (props: QuickLoadingProps) => { toast.loading = (message: ReactNode, data: QuickLoadingProps) => {
toast({ toast({
type: 'loading', type: 'loading',
...props title: message,
...data
}) })
} }
@ -351,125 +430,136 @@ toast.dismiss = (id: ToastProps['id']) => {
sonnerToast.dismiss(id) sonnerToast.dismiss(id)
} }
const toastColorVariants = cva(undefined, { // const toastColorVariants = cva(undefined, {
variants: { // variants: {
type: { // type: {
info: 'text-blue-500', // info: 'text-blue-500',
warning: 'text-warning-base', // warning: 'text-warning-base',
error: 'text-error-base', // error: 'text-error-base',
success: 'text-success-base', // success: 'text-success-base',
loading: 'text-foreground-muted' // loading: 'text-foreground-muted'
} // }
} // }
}) // })
const toastBgColorVariants = cva(undefined, { const toastBgColorVariants = cva(undefined, {
variants: { variants: {
type: { type: {
info: 'bg-blue-500/10 border-blue-500/20', info: 'backdrop-blur-md bg-blue-500/10 border-blue-500/20',
warning: 'bg-orange-500/10 border-orange-500/20', warning: 'backdrop-blur-md bg-orange-500/10 border-orange-500/20',
error: 'bg-red-500/10 border-red-500/20', error: 'backdrop-blur-md bg-red-500/10 border-red-500/20',
success: 'bg-primary/10 border-primary/20', success: 'backdrop-blur-md bg-primary/10 border-primary/20',
loading: 'backdrop-blur-none' loading: 'backdrop-blur-none'
} }
} }
}) })
function Toast({ // function Toast({
id, // id,
type, // type,
title, // title,
description, // description,
coloredMessage, // coloredMessage,
coloredBackground, // colored: coloredBackground,
dismissable, // dismissable,
onDismiss, // onDismiss,
button, // button,
link // link
}: ToastProps) { // }: ToastProps) {
const icon = useMemo(() => { // const icon = useMemo(() => {
switch (type) { // switch (type) {
case 'info': // case 'info':
return <InfoIcon className="size-6" /> // return <InfoIcon className="size-6" />
case 'error': // case 'error':
return <ErrorIcon className="size-6" /> // return <ErrorIcon className="size-6" />
case 'loading': // case 'loading':
return <Loader2Icon className="size-6 animate-spin" /> // return <Loader2Icon className="size-6 animate-spin" />
case 'success': // case 'success':
return <SuccessIcon className="size-6" /> // return <SuccessIcon className="size-6" />
case 'warning': // case 'warning':
return <WarningIcon className="size-6" /> // return <WarningIcon className="size-6" />
} // }
}, [type]) // }, [type])
const handleDismiss = useCallback(() => { // const handleDismiss = useCallback(() => {
sonnerToast.dismiss(id) // sonnerToast.dismiss(id)
onDismiss?.() // onDismiss?.()
}, [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 ( return (
<div <Sonner
id={String(id)} className="toaster group"
className={cn( icons={{
'flex p-4 rounded-xs bg-background border-border border-[0.5px] items-center shadow-lg', info: <InfoIcon />,
coloredBackground && toastBgColorVariants({ type }) success: <SuccessIcon />,
)} warning: <WarningIcon />,
aria-label="Toast"> error: <ErrorIcon />
{dismissable && ( }}
<button type="button" aria-label="Dismiss the toast" onClick={handleDismiss}> {...props}
<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 <Sonner className="toaster group" {...props} />
}
export { toast, Toaster } export { toast, Toaster }

View File

@ -1,7 +1,16 @@
import { Button } from '@cherrystudio/ui' import { Button } from '@cherrystudio/ui'
import { toast, Toaster } from '@cherrystudio/ui' import { toast, Toaster } from '@cherrystudio/ui'
import type { Meta, StoryObj } from '@storybook/react' 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> = { const meta: Meta<typeof Toaster> = {
title: 'Components/Primitives/Sonner', title: 'Components/Primitives/Sonner',
@ -29,14 +38,113 @@ const meta: Meta<typeof Toaster> = {
export default meta export default meta
type Story = StoryObj<typeof 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 // Basic Toast Types
export const Info: Story = { export const Info: Story = {
render: () => ( render: () => (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button <Button
onClick={() => onClick={() =>
toast.info({ toast.info('Information', {
title: 'Information',
description: 'This is an informational message.' description: 'This is an informational message.'
}) })
}> }>
@ -51,8 +159,7 @@ export const Success: Story = {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button <Button
onClick={() => onClick={() =>
toast.success({ toast.success('Success!', {
title: 'Success!',
description: 'Operation completed successfully.' description: 'Operation completed successfully.'
}) })
}> }>
@ -67,8 +174,7 @@ export const ErrorToast: Story = {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button <Button
onClick={() => onClick={() =>
toast.error({ toast.error('Error', {
title: 'Error',
description: 'Something went wrong. Please try again.' description: 'Something went wrong. Please try again.'
}) })
}> }>
@ -83,8 +189,7 @@ export const Warning: Story = {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button <Button
onClick={() => onClick={() =>
toast.warning({ toast.warning('Warning', {
title: 'Warning',
description: 'Please be careful with this action.' description: 'Please be careful with this action.'
}) })
}> }>
@ -104,8 +209,7 @@ export const Loading: Story = {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button <Button
onClick={() => onClick={() =>
toast.loading({ toast.loading('Loading...', {
title: 'Loading...',
description: 'Please wait while we process your request.', description: 'Please wait while we process your request.',
promise: mockPromise promise: mockPromise
}) })
@ -121,14 +225,13 @@ export const Loading: Story = {
export const AllTypes: Story = { export const AllTypes: Story = {
render: () => ( render: () => (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button onClick={() => toast.info({ title: 'Info Toast' })}>Info</Button> <Button onClick={() => toast.info('Info Toast')}>Info</Button>
<Button onClick={() => toast.success({ title: 'Success Toast' })}>Success</Button> <Button onClick={() => toast.success('Success Toast')}>Success</Button>
<Button onClick={() => toast.warning({ title: 'Warning Toast' })}>Warning</Button> <Button onClick={() => toast.warning('Warning Toast')}>Warning</Button>
<Button onClick={() => toast.error({ title: 'Error Toast' })}>Error</Button> <Button onClick={() => toast.error('Error Toast')}>Error</Button>
<Button <Button
onClick={() => onClick={() =>
toast.loading({ toast.loading('Loading Toast', {
title: 'Loading Toast',
promise: new Promise((resolve) => setTimeout(resolve, 2000)) promise: new Promise((resolve) => setTimeout(resolve, 2000))
}) })
}> }>
@ -144,8 +247,7 @@ export const WithDescription: Story = {
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<Button <Button
onClick={() => onClick={() =>
toast.success({ toast.success('Event Created', {
title: 'Event Created',
description: 'Your event has been created successfully. You can now share it with others.' 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 // With Custom Duration
export const WithColoredMessage: Story = { export const WithCustomDuration: Story = {
render: () => ( render: () => (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
onClick={() => onClick={() =>
toast.info({ toast.info('Quick message', {
title: 'System Update', description: 'This will disappear in 1 second',
coloredMessage: 'New version available!', duration: 1000
description: 'Click the button to update now.'
}) })
}> }>
Info with Colored Message 1 Second
</Button> </Button>
<Button <Button
onClick={() => onClick={() =>
toast.warning({ toast.success('Normal duration', {
title: 'Disk Space Low', description: 'This uses default duration (4 seconds)'
coloredMessage: '95% used',
description: 'Please free up some space.'
}) })
}> }>
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> </Button>
</div> </div>
) )
@ -189,40 +350,36 @@ export const WithColoredBackground: Story = {
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
onClick={() => onClick={() =>
toast.info({ toast.info('Information', {
title: 'Information',
description: 'This toast has a colored background.', description: 'This toast has a colored background.',
coloredBackground: true colored: true
}) })
}> }>
Info Background Info Background
</Button> </Button>
<Button <Button
onClick={() => onClick={() =>
toast.success({ toast.success('Success!', {
title: 'Success!',
description: 'This toast has a colored background.', description: 'This toast has a colored background.',
coloredBackground: true colored: true
}) })
}> }>
Success Background Success Background
</Button> </Button>
<Button <Button
onClick={() => onClick={() =>
toast.warning({ toast.warning('Warning', {
title: 'Warning',
description: 'This toast has a colored background.', description: 'This toast has a colored background.',
coloredBackground: true colored: true
}) })
}> }>
Warning Background Warning Background
</Button> </Button>
<Button <Button
onClick={() => onClick={() =>
toast.error({ toast.error('Error', {
title: 'Error',
description: 'This toast has a colored background.', description: 'This toast has a colored background.',
coloredBackground: true colored: true
}) })
}> }>
Error Background Error Background
@ -231,19 +388,18 @@ export const WithColoredBackground: Story = {
) )
} }
// Colored Background with Actions // Colored Background with Action
export const ColoredBackgroundWithActions: Story = { export const ColoredBackgroundWithAction: Story = {
render: () => ( render: () => (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
<Button <Button
onClick={() => onClick={() =>
toast.success({ toast.success('File Uploaded', {
title: 'File Uploaded',
description: 'Your file has been uploaded successfully.', description: 'Your file has been uploaded successfully.',
coloredBackground: true, colored: true,
button: { button: {
label: 'View', label: 'View',
onClick: () => toast.info({ title: 'Opening file...' }) onClick: () => toast.info('Opening file...')
} }
}) })
}> }>
@ -251,138 +407,29 @@ export const ColoredBackgroundWithActions: Story = {
</Button> </Button>
<Button <Button
onClick={() => onClick={() =>
toast.warning({ toast.warning('Action Required', {
title: 'Action Required',
description: 'Please review the changes.', description: 'Please review the changes.',
coloredBackground: true, colored: true,
link: { button: {
label: 'Review', label: 'Review',
onClick: () => toast.info({ title: 'Opening review...' }) onClick: () => toast.info('Opening review...')
} }
}) })
}> }>
Warning with Link Warning with Button
</Button> </Button>
<Button <Button
onClick={() => onClick={() =>
toast.error({ toast.error('Update Failed', {
title: 'Update Failed',
description: 'Failed to update the record.', description: 'Failed to update the record.',
coloredBackground: true, colored: true,
button: { button: {
icon: <RefreshCwIcon className="h-4 w-4" />,
label: 'Retry', label: 'Retry',
onClick: () => toast.info({ title: 'Retrying...' }) onClick: () => toast.info('Retrying...')
},
link: {
label: 'Learn More',
onClick: () => toast.info({ title: 'Opening help...' })
} }
}) })
}> }>
Error with Button & Link Error with Button
</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
</Button> </Button>
</div> </div>
) )
@ -392,10 +439,10 @@ export const DismissableToast: Story = {
export const MultipleToasts: Story = { export const MultipleToasts: Story = {
render: () => { render: () => {
const showMultiple = () => { const showMultiple = () => {
toast.success({ title: 'First notification', description: 'This is the first message' }) toast.success('First notification', { description: 'This is the first message' })
setTimeout(() => toast.info({ title: 'Second notification', description: 'This is the second message' }), 100) setTimeout(() => toast.info('Second notification', { description: 'This is the second message' }), 100)
setTimeout(() => toast.warning({ title: 'Third notification', description: 'This is the third message' }), 200) setTimeout(() => toast.warning('Third notification', { description: 'This is the third message' }), 200)
setTimeout(() => toast.error({ title: 'Fourth notification', description: 'This is the fourth message' }), 300) setTimeout(() => toast.error('Fourth notification', { description: 'This is the fourth message' }), 300)
} }
return ( return (
@ -416,8 +463,7 @@ export const PromiseExample: Story = {
}, 2000) }, 2000)
}) })
toast.loading({ toast.loading('Fetching data...', {
title: 'Fetching data...',
description: 'Please wait while we load your information.', description: 'Please wait while we load your information.',
promise promise
}) })
@ -436,60 +482,54 @@ export const RealWorldExamples: Story = {
render: () => { render: () => {
const handleFileSave = () => { const handleFileSave = () => {
const promise = new Promise((resolve) => setTimeout(resolve, 1500)) const promise = new Promise((resolve) => setTimeout(resolve, 1500))
toast.loading({ toast.loading('Saving file...', {
title: 'Saving file...',
promise promise
}) })
promise.then(() => { promise.then(() => {
toast.success({ toast.success('File saved', {
title: 'File saved', description: 'Your file has been saved successfully.',
description: 'Your file has been saved successfully.' button: {
label: 'View',
onClick: () => toast.info('Opening file...')
}
}) })
}) })
} }
const handleFormSubmit = () => { const handleFormSubmit = () => {
toast.success({ toast.success('Form submitted', {
title: 'Form submitted',
description: 'Your changes have been saved successfully.', description: 'Your changes have been saved successfully.',
button: { button: {
label: 'View', label: 'Undo',
onClick: () => toast.info({ title: 'Opening form...' }) onClick: () => toast.info('Undoing changes...')
} }
}) })
} }
const handleDelete = () => { const handleDelete = () => {
toast.error({ toast.error('Failed to delete', {
title: 'Failed to delete',
description: 'You do not have permission to delete this item.', description: 'You do not have permission to delete this item.',
button: { button: {
icon: <RefreshCwIcon className="h-4 w-4" />,
label: 'Retry', label: 'Retry',
onClick: () => toast.info({ title: 'Retrying...' }) onClick: () => toast.info('Retrying...')
} }
}) })
} }
const handleCopy = () => { const handleCopy = () => {
navigator.clipboard.writeText('https://example.com') navigator.clipboard.writeText('https://example.com')
toast.success({ toast.success('Copied to clipboard', {
title: 'Copied to clipboard',
description: 'The link has been copied to your clipboard.' description: 'The link has been copied to your clipboard.'
}) })
} }
const handleUpdate = () => { const handleUpdate = () => {
toast.info({ toast.info('Update available', {
title: 'Update available',
description: 'A new version of the application is ready to install.', description: 'A new version of the application is ready to install.',
colored: true,
button: { button: {
label: 'Update Now', label: 'Update Now',
onClick: () => toast.info({ title: 'Starting update...' }) onClick: () => toast.info('Starting update...')
},
link: {
label: 'Release Notes',
onClick: () => toast.info({ title: 'Opening release notes...' })
} }
}) })
} }