Parcourir la source

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

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Aarav Sareen il y a 2 semaines
Parent
commit
a9eda2e99e

+ 4 - 0
bun.lock

@@ -1002,6 +1002,7 @@
         "remend": "catalog:",
         "remend": "catalog:",
         "shiki": "catalog:",
         "shiki": "catalog:",
         "solid-list": "catalog:",
         "solid-list": "catalog:",
+        "solid-sonner": "catalog:",
         "strip-ansi": "7.1.2",
         "strip-ansi": "7.1.2",
       },
       },
       "devDependencies": {
       "devDependencies": {
@@ -1146,6 +1147,7 @@
     "shiki": "4.2.0",
     "shiki": "4.2.0",
     "solid-js": "1.9.10",
     "solid-js": "1.9.10",
     "solid-list": "0.3.0",
     "solid-list": "0.3.0",
+    "solid-sonner": "0.3.1",
     "sst": "4.13.1",
     "sst": "4.13.1",
     "tailwindcss": "4.1.11",
     "tailwindcss": "4.1.11",
     "typescript": "5.8.2",
     "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-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-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=="],
     "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=="],

+ 1 - 0
package.json

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

+ 2 - 2
packages/app/package.json

@@ -19,9 +19,9 @@
     "build": "vite build",
     "build": "vite build",
     "serve": "vite preview",
     "serve": "vite preview",
     "test": "bun run test:unit && bun run test:browser",
     "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: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": "playwright test",
     "test:e2e:local": "playwright test",
     "test:e2e:local": "playwright test",
     "test:e2e:ui": "playwright test --ui",
     "test:e2e:ui": "playwright test --ui",

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

+ 15 - 2
packages/app/src/utils/toast.tsx

@@ -1,6 +1,12 @@
 import { Icon, type IconProps } from "@opencode-ai/ui/icon"
 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
 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) {
 function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) {
   const name = icon ?? (variant === "success" ? "check" : undefined)
   const name = icon ?? (variant === "success" ? "check" : undefined)
   if (!name) return
   if (!name) return

+ 39 - 1
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 { createSignal, type JSX } from "solid-js"
 import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
 import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2"
 
 
 describe("showToastV2", () => {
 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", () => {
   test("creates no reactive computations at call time", () => {
     const [tick, setTick] = createSignal(0)
     const [tick, setTick] = createSignal(0)
     let reads = 0
     let reads = 0

+ 1 - 0
packages/ui/package.json

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

+ 132 - 122
packages/ui/src/v2/components/toast-v2.css

@@ -1,114 +1,133 @@
-[data-component="toast-v2-region"] {
-  position: fixed;
-  bottom: 48px;
-  right: 32px;
+.toast-v2-region {
   z-index: 1000;
   z-index: 1000;
-  display: flex;
-  flex-direction: column;
-  gap: 12px;
   max-width: min(320px, calc(100vw - 64px));
   max-width: min(320px, calc(100vw - 64px));
-  max-height: none;
-  width: 100%;
-  overflow: visible;
   pointer-events: none;
   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;
   width: 320px;
+  max-width: 100%;
   padding: 12px;
   padding: 12px;
   max-height: min(420px, calc(100dvh - 96px));
   max-height: min(420px, calc(100dvh - 96px));
   overflow: hidden;
   overflow: hidden;
   pointer-events: auto;
   pointer-events: auto;
-  transition: transform 140ms ease-out;
-
+  box-sizing: border-box;
+  outline: none;
+  user-select: none;
+  -webkit-user-select: none;
   border-radius: 8px;
   border-radius: 8px;
   color: var(--v2-text-text-base);
   color: var(--v2-text-text-base);
   background: var(--v2-background-bg-layer-01);
   background: var(--v2-background-bg-layer-01);
   box-shadow: var(--v2-elevation-floating);
   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;
+
+  &:has(> [data-icon]) {
+    grid-template-columns: 16px minmax(0, 1fr) 20px;
+  }
 
 
-  &[data-opened] {
-    animation: toastV2PopIn 140ms ease-out;
+  &:focus-visible {
+    box-shadow:
+      var(--v2-elevation-floating),
+      0 0 0 2px var(--v2-border-border-focus);
   }
   }
 
 
-  &[data-closed] {
-    animation: toastV2PopOut 100ms ease-in forwards;
+  &[data-expanded="true"] {
+    overflow: visible;
   }
   }
 
 
-  [data-slot="toast-v2-header"] {
-    display: flex;
-    align-items: flex-start;
-    gap: 12px;
-    width: 100%;
+  &[data-expanded="false"][data-front="false"] > * {
+    opacity: 0;
   }
   }
 
 
-  [data-slot="toast-v2-icon"] {
-    flex-shrink: 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;
     display: flex;
     align-items: center;
     align-items: center;
     justify-content: center;
     justify-content: center;
     width: 16px;
     width: 16px;
     height: 20px;
     height: 20px;
-    min-width: 16px;
-    min-height: 20px;
     color: var(--v2-icon-icon-base);
     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"] {
-    flex: 1;
+  > [data-content] {
+    grid-column: 1;
+    grid-row: 1;
     display: flex;
     display: flex;
     flex-direction: column;
     flex-direction: column;
     gap: 4px;
     gap: 4px;
-    min-height: 0;
     min-width: 0;
     min-width: 0;
     overflow: hidden;
     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"] {
   [data-slot="toast-v2-title"] {
     color: var(--v2-text-text-base);
     color: var(--v2-text-text-base);
     overflow: hidden;
     overflow: hidden;
@@ -123,6 +142,7 @@
     margin: 0;
     margin: 0;
   }
   }
 
 
+  [data-description],
   [data-slot="toast-v2-description"] {
   [data-slot="toast-v2-description"] {
     color: var(--v2-text-text-muted);
     color: var(--v2-text-text-muted);
     text-wrap-style: pretty;
     text-wrap-style: pretty;
@@ -138,10 +158,15 @@
   }
   }
 
 
   [data-slot="toast-v2-actions"] {
   [data-slot="toast-v2-actions"] {
+    grid-column: 1;
+    grid-row: 2;
     display: flex;
     display: flex;
     flex-wrap: wrap;
     flex-wrap: wrap;
     gap: 8px;
     gap: 8px;
-    padding-left: 28px;
+  }
+
+  &:has(> [data-icon]) [data-slot="toast-v2-actions"] {
+    grid-column: 2;
   }
   }
 
 
   [data-slot="toast-v2-actions"] [data-component="button-v2"] {
   [data-slot="toast-v2-actions"] [data-component="button-v2"] {
@@ -154,62 +179,47 @@
     font-synthesis: none;
     font-synthesis: none;
   }
   }
 
 
-  [data-slot="toast-v2-close-button"] {
+  [data-slot="toast-v2-header"] {
+    display: flex;
+    align-items: flex-start;
+    gap: 12px;
+    width: 100%;
+  }
+
+  [data-slot="toast-v2-icon"] {
     flex-shrink: 0;
     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;
+    display: flex;
     align-items: center;
     align-items: center;
     justify-content: center;
     justify-content: center;
+    width: 16px;
+    height: 20px;
+    color: var(--v2-icon-icon-base);
+  }
 
 
-    &: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;
-    }
+  [data-slot="toast-v2-content"] {
+    flex: 1;
+    display: flex;
+    flex-direction: column;
+    gap: 4px;
+    min-width: 0;
+    overflow: hidden;
   }
   }
 }
 }
 
 
-@keyframes toastV2PopIn {
-  from {
-    opacity: 0.8;
-    transform: translateY(6px);
-  }
-  to {
-    transform: translateY(0);
-    opacity: 1;
+@media (prefers-reduced-motion: reduce) {
+  .toast-v2,
+  .toast-v2 > * {
+    animation: none !important;
+    transition: none !important;
   }
   }
 }
 }
 
 
-@keyframes toastV2PopOut {
-  from {
-    transform: translateY(0);
-    opacity: 1;
+@media (max-width: 600px) {
+  .toast-v2-region {
+    max-width: calc(100vw - 32px);
   }
   }
-  to {
-    transform: translateY(6px);
-    opacity: 0.8;
+
+  .toast-v2 {
+    width: 100%;
   }
   }
 }
 }

+ 4 - 4
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.
 - Toasts render in a portal and auto-dismiss unless persistent.
 
 
 ### Accessibility
 ### 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
 ### Theming/tokens
 - Uses \`data-component="toast-v2"\` and slot data attributes.
 - 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">
               <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
                 <path
                 <path
                   d="M13.5554 10.4453V13.5564H11.7777H4.22211C3.23989 13.5564 2.44434 13.5564 2.44434 13.5564V10.4453"
                   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>
               </svg>
             ),
             ),
             actions: [
             actions: [

+ 204 - 73
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 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 { Portal } from "solid-js/web"
-import { ButtonV2 } from "./button-v2"
+import "./button-v2.css"
 import "./toast-v2.css"
 import "./toast-v2.css"
 
 
-export interface ToastV2RegionProps extends ComponentProps<typeof Kobalte.Region> {}
+export interface ToastV2RegionProps extends ToasterProps {}
 
 
 function ToastV2Region(props: ToastV2RegionProps) {
 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 (
   return (
     <Portal>
     <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>
     </Portal>
   )
   )
 }
 }
 
 
-export interface ToastV2RootComponentProps extends ToastRootProps {
-  class?: string
-  classList?: ComponentProps<"li">["classList"]
+const ToastV2Context = createContext<number>()
+
+export interface ToastV2RootComponentProps {
+  toastId: number
   children?: JSX.Element
   children?: JSX.Element
 }
 }
 
 
 function ToastV2Root(props: ToastV2RootComponentProps) {
 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">) {
 function ToastV2Icon(props: ComponentProps<"div">) {
@@ -45,26 +83,43 @@ function ToastV2Content(props: ComponentProps<"div">) {
   return <div data-slot="toast-v2-content" {...props} />
   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">) {
 function ToastV2Actions(props: ComponentProps<"div">) {
   return <div data-slot="toast-v2-actions" {...props} />
   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 (
   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,
   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 {
 export interface ToastV2Action {
   label: string
   label: string
@@ -90,53 +167,107 @@ export interface ToastV2Options {
   title?: string
   title?: string
   description?: string
   description?: string
   icon?: JSX.Element
   icon?: JSX.Element
+  variant?: "default" | "success" | "error" | "loading"
   duration?: number
   duration?: number
   persistent?: boolean
   persistent?: boolean
   actions?: ToastV2Action[]
   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) {
 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> {
 export interface ToastV2PromiseOptions<T, U = unknown> {