diff --git a/bun.lock b/bun.lock index aaf43212b70..9f6ef7b6284 100644 --- a/bun.lock +++ b/bun.lock @@ -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=="], diff --git a/package.json b/package.json index e479eb74062..2a8eef63568 100644 --- a/package.json +++ b/package.json @@ -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" } diff --git a/packages/app/package.json b/packages/app/package.json index 7199ad94254..08eb9a059f7 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -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", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b348b44d8a8..694b610903b 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -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 diff --git a/packages/app/src/utils/toast.tsx b/packages/app/src/utils/toast.tsx index e4445485082..6f23b63d1e8 100644 --- a/packages/app/src/utils/toast.tsx +++ b/packages/app/src/utils/toast.tsx @@ -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 diff --git a/packages/app/test-browser/toast-owner.test.ts b/packages/app/test-browser/toast-owner.test.ts index 104a5058516..25ba5c000e1 100644 --- a/packages/app/test-browser/toast-owner.test.ts +++ b/packages/app/test-browser/toast-owner.test.ts @@ -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 diff --git a/packages/ui/package.json b/packages/ui/package.json index 05c4ffbf63b..f994110b9f6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -104,6 +104,7 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", + "solid-sonner": "catalog:", "strip-ansi": "7.1.2" }, "peerDependencies": { diff --git a/packages/ui/src/v2/components/toast-v2.css b/packages/ui/src/v2/components/toast-v2.css index 5bce87dbdc7..db173489a9f 100644 --- a/packages/ui/src/v2/components/toast-v2.css +++ b/packages/ui/src/v2/components/toast-v2.css @@ -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%; } } diff --git a/packages/ui/src/v2/components/toast-v2.stories.tsx b/packages/ui/src/v2/components/toast-v2.stories.tsx index f096459739f..b2af8c038dd 100644 --- a/packages/ui/src/v2/components/toast-v2.stories.tsx +++ b/packages/ui/src/v2/components/toast-v2.stories.tsx @@ -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 = { - - + + ), actions: [ diff --git a/packages/ui/src/v2/components/toast-v2.tsx b/packages/ui/src/v2/components/toast-v2.tsx index d2dc1b3605f..efb6ecd40cf 100644 --- a/packages/ui/src/v2/components/toast-v2.tsx +++ b/packages/ui/src/v2/components/toast-v2.tsx @@ -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 {} +export interface ToastV2RegionProps extends ToasterProps {} function ToastV2Region(props: ToastV2RegionProps) { + const [local, rest] = splitProps(props, ["class", "className", "style", "toastOptions", "swipeDirections"]) + onMount(() => { + const sync = () => { + document.querySelectorAll(".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 ( - - - + ) } -export interface ToastV2RootComponentProps extends ToastRootProps { - class?: string - classList?: ComponentProps<"li">["classList"] +const ToastV2Context = createContext() + +export interface ToastV2RootComponentProps { + toastId: number children?: JSX.Element } function ToastV2Root(props: ToastV2RootComponentProps) { - return ( - - ) + return {props.children} } function ToastV2Icon(props: ComponentProps<"div">) { @@ -45,26 +83,43 @@ function ToastV2Content(props: ComponentProps<"div">) { return
} -function ToastV2Title(props: ToastTitleProps & ComponentProps<"div">) { - return +function ToastV2Title(props: ComponentProps<"div">) { + return
} -function ToastV2Description(props: ToastDescriptionProps & ComponentProps<"div">) { - return +function ToastV2Description(props: ComponentProps<"div">) { + return
} function ToastV2Actions(props: ComponentProps<"div">) { return
} -function ToastV2CloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) { +function ToastV2CloseButton(props: ComponentProps<"button">) { + const toastId = useContext(ToastV2Context) + const [local, rest] = splitProps(props, ["children", "onClick"]) return ( - - - + + ) +} + +function CloseIcon() { + return ( + ) } @@ -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() +const activeToastV2ById = new Map() + export function showToastV2(options: ToastV2Options | string) { - const opts = typeof options === "string" ? { description: options } : options - return toaster.show((props) => { - const resolvedIcon = children(() => opts.icon) - return ( - -
- - {resolvedIcon()} - - - - {opts.title} - - - {opts.description} - - - -
- - - {opts.actions!.map((action) => ( - { - if (typeof action.onClick === "function") { - action.onClick() - } - toaster.dismiss(props.toastId) - }} - > - {action.label} - - ))} - - -
- ) + 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 ( + + {entry.options.actions.map((action, index) => ( + + ))} + + ) +} + +function pulseToastV2(toastId: number) { + if (typeof document === "undefined" || typeof requestAnimationFrame === "undefined") return + requestAnimationFrame(() => { + const element = document.querySelector(`[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 {