feat(app): migrate to solid-sonner (#39519)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
Aarav Sareen 2026-07-30 14:27:19 +05:30 committed by GitHub
parent 73009f0b09
commit a9eda2e99e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 447 additions and 250 deletions

View file

@ -1002,6 +1002,7 @@
"remend": "catalog:",
"shiki": "catalog:",
"solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2",
},
"devDependencies": {
@ -1146,6 +1147,7 @@
"shiki": "4.2.0",
"solid-js": "1.9.10",
"solid-list": "0.3.0",
"solid-sonner": "0.3.1",
"sst": "4.13.1",
"tailwindcss": "4.1.11",
"typescript": "5.8.2",
@ -5127,6 +5129,8 @@
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
"solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="],
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],

View file

@ -90,6 +90,7 @@
"@sentry/solid": "10.36.0",
"@sentry/vite-plugin": "4.6.0",
"solid-js": "1.9.10",
"solid-sonner": "0.3.1",
"vite-plugin-solid": "2.11.10",
"@lydell/node-pty": "1.2.0-beta.12"
}

View file

@ -19,9 +19,9 @@
"build": "vite build",
"serve": "vite preview",
"test": "bun run test:unit && bun run test:browser",
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
"test:e2e": "playwright test",
"test:e2e:local": "playwright test",
"test:e2e:ui": "playwright test --ui",

View file

@ -33,8 +33,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
import type { DragEvent } from "@thisbeyond/solid-dnd"
import { useProviders } from "@/hooks/use-providers"
import { toaster } from "@opencode-ai/ui/toast"
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { dismissToast, setV2Toast, showToast, ToastRegion } from "@/utils/toast"
import { useServerSDK } from "@/context/server-sdk"
import { normalizeProjectInfo } from "@/context/global-sync/utils"
import { clearWorkspaceTerminals } from "@/context/terminal"
@ -384,7 +383,7 @@ export default function LegacyLayout(props: ParentProps) {
const dismissSessionAlert = (sessionKey: string) => {
const toastId = toastBySession.get(sessionKey)
if (toastId === undefined) return
toaster.dismiss(toastId)
dismissToast(toastId)
toastBySession.delete(sessionKey)
alertedAtBySession.delete(sessionKey)
}
@ -1459,7 +1458,7 @@ export default function LegacyLayout(props: ParentProps) {
title: language.t("workspace.resetting.title"),
description: language.t("workspace.resetting.description"),
})
const dismiss = () => toaster.dismiss(progress)
const dismiss = () => dismissToast(progress)
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
@ -2450,7 +2449,7 @@ function UpdateAvailableToast(props: {
onCleanup(() => {
if (toastId === undefined) return
toaster.dismiss(toastId)
dismissToast(toastId)
})
return null

View file

@ -1,6 +1,12 @@
import { Icon, type IconProps } from "@opencode-ai/ui/icon"
import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast"
import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2"
import {
Toast,
showToast as showLegacyToast,
toaster as legacyToaster,
type ToastOptions,
type ToastVariant,
} from "@opencode-ai/ui/toast"
import { ToastV2, showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
let v2 = false
@ -27,6 +33,13 @@ export function showToast(options: ToastOptions | string) {
})
}
// v1 and v2 ids come from separate registries, so dismissal has to use the same
// implementation that issued the id.
export function dismissToast(toastId: number) {
if (!v2) return legacyToaster.dismiss(toastId)
return toasterV2.dismiss(toastId)
}
function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) {
const name = icon ?? (variant === "success" ? "check" : undefined)
if (!name) return

View file

@ -1,8 +1,46 @@
import { describe, expect, test } from "bun:test"
import { beforeEach, describe, expect, test } from "bun:test"
import { createSignal, type JSX } from "solid-js"
import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
describe("showToastV2", () => {
// The toast registry is module state, so each test starts from an empty stack.
beforeEach(() => {
toasterV2.dismiss()
})
test("coalesces exact active content", () => {
const first = showToastV2({ title: "Repeated error", description: "Try again" })
const second = showToastV2({ title: "Repeated error", description: "Try again" })
const different = showToastV2({ title: "Repeated error", description: "A different error" })
expect(second).toBe(first)
expect(different).not.toBe(first)
toasterV2.dismiss(first)
toasterV2.dismiss(different)
})
test("allows dismissed content to appear again", () => {
const first = showToastV2("Dismiss and retry")
toasterV2.dismiss(first)
const second = showToastV2("Dismiss and retry")
expect(second).not.toBe(first)
toasterV2.dismiss(second)
})
test("recreates matching content when it is not the topmost toast", () => {
const first = showToastV2("First toast")
const topmost = showToastV2("Topmost toast")
const repeated = showToastV2("First toast")
expect(repeated).not.toBe(first)
toasterV2.dismiss(topmost)
toasterV2.dismiss(repeated)
})
test("creates no reactive computations at call time", () => {
const [tick, setTick] = createSignal(0)
let reads = 0

View file

@ -104,6 +104,7 @@
"remend": "catalog:",
"shiki": "catalog:",
"solid-list": "catalog:",
"solid-sonner": "catalog:",
"strip-ansi": "7.1.2"
},
"peerDependencies": {

View file

@ -1,58 +1,182 @@
[data-component="toast-v2-region"] {
position: fixed;
bottom: 48px;
right: 32px;
.toast-v2-region {
z-index: 1000;
display: flex;
flex-direction: column;
gap: 12px;
max-width: min(320px, calc(100vw - 64px));
max-height: none;
width: 100%;
overflow: visible;
pointer-events: none;
[data-slot="toast-v2-list"] {
display: flex;
flex-direction: column;
gap: 12px;
list-style: none;
margin: 0;
padding: 0;
max-height: none;
overflow-y: visible;
overflow-x: visible;
scrollbar-width: none;
&::-webkit-scrollbar {
display: none;
}
}
user-select: none;
-webkit-user-select: none;
}
[data-component="toast-v2"] {
position: relative;
display: flex;
flex-direction: column;
gap: 12px;
.toast-v2 {
display: grid;
grid-template-columns: minmax(0, 1fr) 20px;
column-gap: 12px;
row-gap: 12px;
width: 320px;
max-width: 100%;
padding: 12px;
max-height: min(420px, calc(100dvh - 96px));
overflow: hidden;
pointer-events: auto;
transition: transform 140ms ease-out;
box-sizing: border-box;
outline: none;
user-select: none;
-webkit-user-select: none;
border-radius: 8px;
color: var(--v2-text-text-base);
background: var(--v2-background-bg-layer-01);
box-shadow: var(--v2-elevation-floating);
transition:
transform 280ms cubic-bezier(0.2, 0, 0, 1),
opacity 160ms ease-out,
height 280ms cubic-bezier(0.2, 0, 0, 1),
box-shadow 160ms ease-out;
&[data-opened] {
animation: toastV2PopIn 140ms ease-out;
&:has(> [data-icon]) {
grid-template-columns: 16px minmax(0, 1fr) 20px;
}
&[data-closed] {
animation: toastV2PopOut 100ms ease-in forwards;
&:focus-visible {
box-shadow:
var(--v2-elevation-floating),
0 0 0 2px var(--v2-border-border-focus);
}
&[data-expanded="true"] {
overflow: visible;
}
&[data-expanded="false"][data-front="false"] > * {
opacity: 0;
}
&[data-expanded="false"][data-front="false"] {
--y: translateY(calc(-1 * var(--toasts-before) * 9.5px)) scale(calc(1 - var(--toasts-before) * 0.05));
}
> [data-icon] {
grid-column: 1;
grid-row: 1;
align-self: start;
display: flex;
align-items: center;
justify-content: center;
width: 16px;
height: 20px;
color: var(--v2-icon-icon-base);
}
> [data-content] {
grid-column: 1;
grid-row: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
overflow: hidden;
}
&:has(> [data-icon]) > [data-content] {
grid-column: 2;
}
> [data-close-button] {
grid-column: 2;
grid-row: 1;
position: static;
align-self: start;
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
transform: none;
background: transparent;
color: var(--v2-icon-icon-muted);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
}
&:has(> [data-icon]) > [data-close-button] {
grid-column: 3;
}
> [data-close-button]:hover {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-icon-icon-base);
}
> [data-close-button]:active {
background: var(--v2-overlay-simple-overlay-pressed);
}
> [data-close-button]:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
> [data-icon] > *,
> [data-icon] svg,
> [data-close-button] svg {
width: 16px;
height: 16px;
display: block;
flex-shrink: 0;
}
[data-title],
[data-slot="toast-v2-title"] {
color: var(--v2-text-text-base);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
margin: 0;
}
[data-description],
[data-slot="toast-v2-description"] {
color: var(--v2-text-text-muted);
text-wrap-style: pretty;
overflow-wrap: anywhere;
word-break: break-word;
font-size: 13px;
font-style: normal;
font-weight: 440;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
margin: 0;
}
[data-slot="toast-v2-actions"] {
grid-column: 1;
grid-row: 2;
display: flex;
flex-wrap: wrap;
gap: 8px;
}
&:has(> [data-icon]) [data-slot="toast-v2-actions"] {
grid-column: 2;
}
[data-slot="toast-v2-actions"] [data-component="button-v2"] {
min-height: 24px;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
}
[data-slot="toast-v2-header"] {
@ -69,34 +193,7 @@
justify-content: center;
width: 16px;
height: 20px;
min-width: 16px;
min-height: 20px;
color: var(--v2-icon-icon-base);
[data-component="icon"] {
color: var(--v2-icon-icon-base);
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
}
> * {
width: 16px;
height: 16px;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
svg {
width: 16px;
height: 16px;
display: block;
flex-shrink: 0;
}
}
[data-slot="toast-v2-content"] {
@ -104,112 +201,25 @@
display: flex;
flex-direction: column;
gap: 4px;
min-height: 0;
min-width: 0;
overflow: hidden;
}
}
[data-slot="toast-v2-title"] {
color: var(--v2-text-text-base);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
margin: 0;
}
[data-slot="toast-v2-description"] {
color: var(--v2-text-text-muted);
text-wrap-style: pretty;
overflow-wrap: anywhere;
word-break: break-word;
font-size: 13px;
font-style: normal;
font-weight: 440;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
margin: 0;
}
[data-slot="toast-v2-actions"] {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding-left: 28px;
}
[data-slot="toast-v2-actions"] [data-component="button-v2"] {
min-height: 24px;
font-size: 13px;
font-style: normal;
font-weight: 530;
line-height: 20px;
letter-spacing: -0.04px;
font-synthesis: none;
}
[data-slot="toast-v2-close-button"] {
flex-shrink: 0;
width: 20px;
height: 20px;
min-width: 20px;
min-height: 20px;
padding: 0;
border: 0;
border-radius: 4px;
background: transparent;
color: var(--v2-icon-icon-muted);
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
&:hover {
background: var(--v2-overlay-simple-overlay-hover);
color: var(--v2-icon-icon-base);
}
&:active {
background: var(--v2-overlay-simple-overlay-pressed);
}
&:focus-visible {
outline: 2px solid var(--v2-border-border-focus);
outline-offset: 2px;
}
svg {
width: 16px;
height: 16px;
display: block;
}
@media (prefers-reduced-motion: reduce) {
.toast-v2,
.toast-v2 > * {
animation: none !important;
transition: none !important;
}
}
@keyframes toastV2PopIn {
from {
opacity: 0.8;
transform: translateY(6px);
@media (max-width: 600px) {
.toast-v2-region {
max-width: calc(100vw - 32px);
}
to {
transform: translateY(0);
opacity: 1;
}
}
@keyframes toastV2PopOut {
from {
transform: translateY(0);
opacity: 1;
}
to {
transform: translateY(6px);
opacity: 0.8;
.toast-v2 {
width: 100%;
}
}

View file

@ -19,7 +19,7 @@ Use brief titles/descriptions; limit actions to 1-2.
- Toasts render in a portal and auto-dismiss unless persistent.
### Accessibility
- TODO: confirm aria-live behavior from Kobalte Toast.
- The region announces additions through an ARIA live region and expands with Alt+T.
### Theming/tokens
- Uses \`data-component="toast-v2"\` and slot data attributes.
@ -57,10 +57,10 @@ export const AllExamples = {
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M13.5554 10.4453V13.5564H11.7777H4.22211C3.23989 13.5564 2.44434 13.5564 2.44434 13.5564V10.4453"
stroke="var(--icon-icon-base)"
stroke="currentColor"
/>
<path d="M4.88867 6L7.99978 9.11111L11.1109 6" stroke="var(--icon-icon-base)" />
<path d="M8 9.11198V2.44531" stroke="var(--icon-icon-base)" />
<path d="M4.88867 6L7.99978 9.11111L11.1109 6" stroke="currentColor" />
<path d="M8 9.11198V2.44531" stroke="currentColor" />
</svg>
),
actions: [

View file

@ -1,40 +1,78 @@
import { Toast as Kobalte, toaster } from "@kobalte/core/toast"
import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast"
import { Toaster, toast, type ToasterProps } from "solid-sonner"
import type { ComponentProps, JSX } from "solid-js"
import { Show, children } from "solid-js"
import { createContext, onCleanup, onMount, splitProps, useContext } from "solid-js"
import { Portal } from "solid-js/web"
import { ButtonV2 } from "./button-v2"
import "./button-v2.css"
import "./toast-v2.css"
export interface ToastV2RegionProps extends ComponentProps<typeof Kobalte.Region> {}
export interface ToastV2RegionProps extends ToasterProps {}
function ToastV2Region(props: ToastV2RegionProps) {
const [local, rest] = splitProps(props, ["class", "className", "style", "toastOptions", "swipeDirections"])
onMount(() => {
const sync = () => {
document.querySelectorAll<HTMLElement>(".toast-v2-region .toast-v2").forEach((element) => {
const hidden = element.dataset.visible === "false"
element.inert = hidden
element.tabIndex = hidden ? -1 : 0
})
}
let connected = false
const connect = () => {
const regions = document.querySelectorAll(".toast-v2-region")
if (!regions.length) return
observer.disconnect()
regions.forEach((region) => {
observer.observe(region, {
subtree: true,
childList: true,
attributes: true,
attributeFilter: ["data-visible"],
})
})
connected = true
sync()
}
const observer = new MutationObserver(() => {
if (!connected) connect()
else sync()
})
observer.observe(document.body, { subtree: true, childList: true })
queueMicrotask(connect)
onCleanup(() => observer.disconnect())
})
return (
<Portal>
<Kobalte.Region data-component="toast-v2-region" {...props}>
<Kobalte.List data-slot="toast-v2-list" />
</Kobalte.Region>
<Toaster
position="bottom-right"
offset={{ right: 32, bottom: 48 }}
mobileOffset={16}
gap={12}
duration={5000}
swipeDirections={["bottom"]}
className={["toast-v2-region", local.className, local.class].filter(Boolean).join(" ")}
style={{ "--width": "320px", ...local.style } as JSX.CSSProperties}
toastOptions={{
...local.toastOptions,
unstyled: true,
closeButton: true,
closeButtonAriaLabel: local.toastOptions?.closeButtonAriaLabel ?? "Dismiss",
}}
{...rest}
/>
</Portal>
)
}
export interface ToastV2RootComponentProps extends ToastRootProps {
class?: string
classList?: ComponentProps<"li">["classList"]
const ToastV2Context = createContext<number>()
export interface ToastV2RootComponentProps {
toastId: number
children?: JSX.Element
}
function ToastV2Root(props: ToastV2RootComponentProps) {
return (
<Kobalte
data-component="toast-v2"
classList={{
...props.classList,
[props.class ?? ""]: !!props.class,
}}
{...props}
/>
)
return <ToastV2Context.Provider value={props.toastId}>{props.children}</ToastV2Context.Provider>
}
function ToastV2Icon(props: ComponentProps<"div">) {
@ -45,26 +83,43 @@ function ToastV2Content(props: ComponentProps<"div">) {
return <div data-slot="toast-v2-content" {...props} />
}
function ToastV2Title(props: ToastTitleProps & ComponentProps<"div">) {
return <Kobalte.Title data-slot="toast-v2-title" {...props} />
function ToastV2Title(props: ComponentProps<"div">) {
return <div data-slot="toast-v2-title" {...props} />
}
function ToastV2Description(props: ToastDescriptionProps & ComponentProps<"div">) {
return <Kobalte.Description data-slot="toast-v2-description" {...props} />
function ToastV2Description(props: ComponentProps<"div">) {
return <div data-slot="toast-v2-description" {...props} />
}
function ToastV2Actions(props: ComponentProps<"div">) {
return <div data-slot="toast-v2-actions" {...props} />
}
function ToastV2CloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) {
function ToastV2CloseButton(props: ComponentProps<"button">) {
const toastId = useContext(ToastV2Context)
const [local, rest] = splitProps(props, ["children", "onClick"])
return (
<Kobalte.CloseButton data-slot="toast-v2-close-button" aria-label="Dismiss" {...props}>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M4.25 11.75L11.75 4.25" stroke="currentColor" />
<path d="M11.75 11.75L4.25 4.25" stroke="currentColor" />
</svg>
</Kobalte.CloseButton>
<button
type="button"
data-slot="toast-v2-close-button"
aria-label="Dismiss"
{...rest}
onClick={(event) => {
if (typeof local.onClick === "function") local.onClick(event)
if (!event.defaultPrevented && toastId !== undefined) toasterV2.dismiss(toastId)
}}
>
{local.children ?? <CloseIcon />}
</button>
)
}
function CloseIcon() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
<path d="M4.25 11.75L11.75 4.25" stroke="currentColor" />
<path d="M11.75 11.75L4.25 4.25" stroke="currentColor" />
</svg>
)
}
@ -78,7 +133,29 @@ export const ToastV2 = Object.assign(ToastV2Root, {
CloseButton: ToastV2CloseButton,
})
export { toaster as toasterV2 }
let toastV2Id = 0
export const toasterV2 = {
show(render: (props: { toastId: number }) => JSX.Element, options?: { duration?: number; persistent?: boolean }) {
const toastId = --toastV2Id
toast.custom((id) => render({ toastId: Number(id) }), {
id: toastId,
className: "toast-v2",
duration: options?.persistent ? Number.POSITIVE_INFINITY : options?.duration,
unstyled: true,
})
return toastId
},
dismiss(toastId?: number) {
if (toastId === undefined) {
activeToastV2ByKey.clear()
activeToastV2ById.clear()
} else {
releaseToastV2(activeToastV2ById.get(toastId))
}
return toast.dismiss(toastId)
},
}
export interface ToastV2Action {
label: string
@ -90,53 +167,107 @@ export interface ToastV2Options {
title?: string
description?: string
icon?: JSX.Element
variant?: "default" | "success" | "error" | "loading"
duration?: number
persistent?: boolean
actions?: ToastV2Action[]
}
interface ActiveToastV2 {
id: number
key: string
options: ToastV2Options
actions?: JSX.Element
}
const activeToastV2ByKey = new Map<string, ActiveToastV2>()
const activeToastV2ById = new Map<number, ActiveToastV2>()
export function showToastV2(options: ToastV2Options | string) {
const opts = typeof options === "string" ? { description: options } : options
return toaster.show((props) => {
const resolvedIcon = children(() => opts.icon)
return (
<ToastV2 toastId={props.toastId} duration={opts.duration} persistent={opts.persistent}>
<div data-slot="toast-v2-header">
<Show when={resolvedIcon()}>
<ToastV2.Icon>{resolvedIcon()}</ToastV2.Icon>
</Show>
<ToastV2.Content>
<Show when={opts.title}>
<ToastV2.Title>{opts.title}</ToastV2.Title>
</Show>
<Show when={opts.description}>
<ToastV2.Description>{opts.description}</ToastV2.Description>
</Show>
</ToastV2.Content>
<ToastV2.CloseButton />
</div>
<Show when={opts.actions?.length}>
<ToastV2.Actions>
{opts.actions!.map((action) => (
<ButtonV2
variant={action.variant === "secondary" ? "ghost" : "neutral"}
size="small"
data-action-variant={action.variant ?? "primary"}
onClick={() => {
if (typeof action.onClick === "function") {
action.onClick()
}
toaster.dismiss(props.toastId)
}}
>
{action.label}
</ButtonV2>
))}
</ToastV2.Actions>
</Show>
</ToastV2>
)
const opts: ToastV2Options = typeof options === "string" ? { description: options } : options
const key = JSON.stringify({
title: opts.title,
description: opts.description,
variant: opts.variant,
duration: opts.duration,
persistent: opts.persistent,
actions: opts.actions?.map((action) => [action.label, action.variant]),
})
const active = activeToastV2ByKey.get(key)
const toasts = toast.getToasts()
if (active && toasts.at(-1)?.id === active.id) {
active.options = opts
publishToastV2(active)
pulseToastV2(active.id)
return active.id
}
if (active && toasts.some((item) => item.id === active.id)) toasterV2.dismiss(active.id)
releaseToastV2(active)
const entry: ActiveToastV2 = { id: --toastV2Id, key, options: opts }
entry.actions = createToastV2Actions(entry)
activeToastV2ByKey.set(key, entry)
activeToastV2ById.set(entry.id, entry)
publishToastV2(entry)
return entry.id
}
function publishToastV2(entry: ActiveToastV2) {
const release = () => releaseToastV2(entry)
toast(entry.options.title ?? "", {
id: entry.id,
description: entry.options.description,
icon: entry.options.icon,
action: entry.actions,
closeButton: true,
duration: entry.options.persistent ? Number.POSITIVE_INFINITY : entry.options.duration,
className: `toast-v2 toast-v2--${entry.options.variant ?? "default"}`,
testId: `toast-v2-${entry.id}`,
unstyled: true,
onDismiss: release,
onAutoClose: release,
})
}
function createToastV2Actions(entry: ActiveToastV2) {
if (!entry.options.actions?.length) return undefined
return (
<ToastV2.Actions>
{entry.options.actions.map((action, index) => (
<button
type="button"
data-component="button-v2"
data-variant={action.variant === "secondary" ? "ghost" : "neutral"}
data-size="small"
data-action-variant={action.variant ?? "primary"}
onClick={() => {
const onClick = entry.options.actions?.[index]?.onClick
if (typeof onClick === "function") onClick()
toasterV2.dismiss(entry.id)
}}
>
{action.label}
</button>
))}
</ToastV2.Actions>
)
}
function pulseToastV2(toastId: number) {
if (typeof document === "undefined" || typeof requestAnimationFrame === "undefined") return
requestAnimationFrame(() => {
const element = document.querySelector<HTMLElement>(`[data-testid="toast-v2-${toastId}"]`)
if (!element || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
element.animate([{ scale: 1 }, { scale: 1.025 }, { scale: 1 }], { duration: 160, easing: "ease-out" })
})
}
function releaseToastV2(entry: ActiveToastV2 | undefined) {
if (!entry) return
if (activeToastV2ByKey.get(entry.key) === entry) activeToastV2ByKey.delete(entry.key)
if (activeToastV2ById.get(entry.id) === entry) activeToastV2ById.delete(entry.id)
}
export interface ToastV2PromiseOptions<T, U = unknown> {