mirror of
https://github.com/simstudioai/sim.git
synced 2026-04-06 03:00:16 -04:00
improvement(toast): match notification styling with countdown ring and consistent design (#3688)
* improvement(toast): match notification styling with countdown ring and consistent design * fix(toast): add success variant indicator dot
This commit is contained in:
@@ -18,6 +18,9 @@ const AUTO_DISMISS_MS = 0
|
||||
const EXIT_ANIMATION_MS = 200
|
||||
const MAX_VISIBLE = 20
|
||||
|
||||
const RING_RADIUS = 5.5
|
||||
const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS
|
||||
|
||||
type ToastVariant = 'default' | 'success' | 'error'
|
||||
|
||||
interface ToastAction {
|
||||
@@ -28,7 +31,6 @@ interface ToastAction {
|
||||
interface ToastData {
|
||||
id: string
|
||||
message: string
|
||||
description?: string
|
||||
variant: ToastVariant
|
||||
action?: ToastAction
|
||||
duration: number
|
||||
@@ -36,7 +38,6 @@ interface ToastData {
|
||||
|
||||
type ToastInput = {
|
||||
message: string
|
||||
description?: string
|
||||
variant?: ToastVariant
|
||||
action?: ToastAction
|
||||
duration?: number
|
||||
@@ -90,12 +91,31 @@ export function useToast() {
|
||||
return ctx
|
||||
}
|
||||
|
||||
const VARIANT_STYLES: Record<ToastVariant, string> = {
|
||||
default: 'border-[var(--border)] bg-[var(--bg)] text-[var(--text-primary)]',
|
||||
success:
|
||||
'border-emerald-200 bg-emerald-50 text-emerald-900 dark:border-emerald-800/40 dark:bg-emerald-950/30 dark:text-emerald-200',
|
||||
error:
|
||||
'border-red-200 bg-red-50 text-red-900 dark:border-red-800/40 dark:bg-red-950/30 dark:text-red-200',
|
||||
function CountdownRing({ duration }: { duration: number }) {
|
||||
return (
|
||||
<svg
|
||||
width='14'
|
||||
height='14'
|
||||
viewBox='0 0 16 16'
|
||||
fill='none'
|
||||
xmlns='http://www.w3.org/2000/svg'
|
||||
style={{ transform: 'rotate(-90deg) scaleX(-1)' }}
|
||||
>
|
||||
<circle cx='8' cy='8' r={RING_RADIUS} stroke='var(--border)' strokeWidth='1.5' />
|
||||
<circle
|
||||
cx='8'
|
||||
cy='8'
|
||||
r={RING_RADIUS}
|
||||
stroke='var(--text-icon)'
|
||||
strokeWidth='1.5'
|
||||
strokeLinecap='round'
|
||||
strokeDasharray={RING_CIRCUMFERENCE}
|
||||
style={{
|
||||
animation: `notification-countdown ${duration}ms linear forwards`,
|
||||
}}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id: string) => void }) {
|
||||
@@ -117,38 +137,48 @@ function ToastItem({ toast: t, onDismiss }: { toast: ToastData; onDismiss: (id:
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'pointer-events-auto flex w-[320px] items-start gap-[8px] rounded-[8px] border px-[12px] py-[10px] shadow-md transition-all',
|
||||
VARIANT_STYLES[t.variant],
|
||||
'pointer-events-auto w-[240px] overflow-hidden rounded-[8px] border border-[var(--border)] bg-[var(--bg)] shadow-sm',
|
||||
exiting
|
||||
? 'animate-[toast-exit_200ms_ease-in_forwards]'
|
||||
: 'animate-[toast-enter_200ms_ease-out_forwards]'
|
||||
)}
|
||||
>
|
||||
<div className='min-w-0 flex-1'>
|
||||
<p className='font-medium text-[13px] leading-[18px]'>{t.message}</p>
|
||||
{t.description && (
|
||||
<p className='mt-[2px] text-[12px] leading-[16px] opacity-80'>{t.description}</p>
|
||||
<div className='flex flex-col gap-[8px] p-[8px]'>
|
||||
<div className='flex items-start gap-[8px]'>
|
||||
<div className='line-clamp-2 min-w-0 flex-1 font-medium text-[12px] text-[var(--text-body)]'>
|
||||
{t.variant === 'error' && (
|
||||
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-error)] align-middle' />
|
||||
)}
|
||||
{t.variant === 'success' && (
|
||||
<span className='mr-[8px] mb-[2px] inline-block h-[8px] w-[8px] rounded-[2px] bg-[var(--text-success)] align-middle' />
|
||||
)}
|
||||
{t.message}
|
||||
</div>
|
||||
<div className='flex shrink-0 items-start gap-[2px]'>
|
||||
{t.duration > 0 && <CountdownRing duration={t.duration} />}
|
||||
<button
|
||||
type='button'
|
||||
onClick={dismiss}
|
||||
aria-label='Dismiss notification'
|
||||
className='-m-[2px] shrink-0 rounded-[5px] p-[4px] hover:bg-[var(--surface-active)]'
|
||||
>
|
||||
<X className='h-[14px] w-[14px] text-[var(--text-icon)]' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{t.action && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
t.action!.onClick()
|
||||
dismiss()
|
||||
}}
|
||||
className='w-full rounded-[5px] bg-[var(--surface-active)] px-[8px] py-[4px] font-medium text-[12px] hover:bg-[var(--surface-hover)]'
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{t.action && (
|
||||
<button
|
||||
type='button'
|
||||
onClick={() => {
|
||||
t.action!.onClick()
|
||||
dismiss()
|
||||
}}
|
||||
className='shrink-0 font-medium text-[13px] underline underline-offset-2 opacity-90 hover:opacity-100'
|
||||
>
|
||||
{t.action.label}
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
onClick={dismiss}
|
||||
className='shrink-0 rounded-[4px] p-[2px] opacity-60 hover:opacity-100'
|
||||
>
|
||||
<X className='h-[14px] w-[14px]' />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -175,7 +205,6 @@ export function ToastProvider({ children }: { children?: ReactNode }) {
|
||||
const data: ToastData = {
|
||||
id,
|
||||
message: input.message,
|
||||
description: input.description,
|
||||
variant: input.variant ?? 'default',
|
||||
action: input.action,
|
||||
duration: input.duration ?? AUTO_DISMISS_MS,
|
||||
|
||||
Reference in New Issue
Block a user