From 495abc2126f8b54740bb0e8a6e05f548fb181626 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:42:21 +0530 Subject: [PATCH 1/5] update toast v2 --- packages/app/test-browser/toast-owner.test.ts | 33 ++ packages/ui/LICENSE.sonner | 22 ++ packages/ui/package.json | 1 + .../ui/src/v2/components/toast-v2-stack.tsx | 327 ++++++++++++++++++ packages/ui/src/v2/components/toast-v2.css | 171 ++++++--- .../ui/src/v2/components/toast-v2.stories.tsx | 8 +- packages/ui/src/v2/components/toast-v2.tsx | 234 +++++++++---- 7 files changed, 676 insertions(+), 120 deletions(-) create mode 100644 packages/ui/LICENSE.sonner create mode 100644 packages/ui/src/v2/components/toast-v2-stack.tsx diff --git a/packages/app/test-browser/toast-owner.test.ts b/packages/app/test-browser/toast-owner.test.ts index 104a50585165..4ed848bc9972 100644 --- a/packages/app/test-browser/toast-owner.test.ts +++ b/packages/app/test-browser/toast-owner.test.ts @@ -3,6 +3,39 @@ import { createSignal, type JSX } from "solid-js" import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2" describe("showToastV2", () => { + 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/LICENSE.sonner b/packages/ui/LICENSE.sonner new file mode 100644 index 000000000000..c4328e116da9 --- /dev/null +++ b/packages/ui/LICENSE.sonner @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2023 Emil Kowalski +Copyright (c) 2022 Robert Soriano + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/ui/package.json b/packages/ui/package.json index b8e3ec7172f9..7c0eac21bab5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,6 +30,7 @@ "src/assets/icons/app/*", "src/assets/fonts/*", "src/assets/audio/*", + "LICENSE.sonner", "LICENSE" ], "exports": { diff --git a/packages/ui/src/v2/components/toast-v2-stack.tsx b/packages/ui/src/v2/components/toast-v2-stack.tsx new file mode 100644 index 000000000000..bfbdc589474d --- /dev/null +++ b/packages/ui/src/v2/components/toast-v2-stack.tsx @@ -0,0 +1,327 @@ +// Stack behavior adapted from Sonner and solid-sonner. See LICENSE.sonner. +import type { JSX } from "solid-js" +import { For, createContext, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { createStore, reconcile } from "solid-js/store" + +const DEFAULT_DURATION = 4000 +const REMOVE_DELAY = 180 + +export interface ToastV2StackItem { + id: number + revision: number + variant?: string + duration?: number + persistent?: boolean + render: () => JSX.Element + onDismiss?: () => void + onAutoClose?: () => void +} + +interface RenderedToastV2StackItem extends ToastV2StackItem { + removed: boolean +} + +type StackEvent = { type: "upsert"; item: ToastV2StackItem } | { type: "dismiss"; id: number } + +const listeners = new Set<(event: StackEvent) => void>() +let active: ToastV2StackItem[] = [] + +export const ToastV2StackRenderContext = createContext(false) + +export const toastV2Stack = { + show(item: ToastV2StackItem) { + const index = active.findIndex((toast) => toast.id === item.id) + active = index === -1 ? [item, ...active] : active.map((toast) => (toast.id === item.id ? item : toast)) + listeners.forEach((listener) => listener({ type: "upsert", item })) + return item.id + }, + dismiss(id?: number, auto = false) { + const dismissed = id === undefined ? active : active.filter((toast) => toast.id === id) + active = id === undefined ? [] : active.filter((toast) => toast.id !== id) + dismissed.forEach((toast) => (auto ? toast.onAutoClose?.() : toast.onDismiss?.())) + if (id === undefined) + dismissed.forEach((toast) => listeners.forEach((listener) => listener({ type: "dismiss", id: toast.id }))) + else listeners.forEach((listener) => listener({ type: "dismiss", id })) + return id + }, + getToasts() { + return active + }, + subscribe(listener: (event: StackEvent) => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, +} + +export interface ToastV2StackRegionProps { + class?: string + className?: string + duration?: number + gap?: number + visibleToasts?: number + offset?: { right?: number; bottom?: number } + mobileOffset?: number + containerAriaLabel?: string +} + +export function ToastV2StackRegion(props: ToastV2StackRegionProps) { + const [store, setStore] = createStore<{ + items: RenderedToastV2StackItem[] + heights: Record + expanded: boolean + interacting: boolean + }>({ + items: toastV2Stack.getToasts().map((item) => ({ ...item, removed: false }) satisfies RenderedToastV2StackItem), + heights: {} as Record, + expanded: false, + interacting: false, + }) + const removals = new Set>() + let list: HTMLOListElement | undefined + + onMount(() => { + const unsubscribe = toastV2Stack.subscribe((event) => { + if (event.type === "dismiss") { + const index = store.items.findIndex((item) => item.id === event.id) + if (index === -1) return + setStore("items", index, "removed", true) + const timeout = setTimeout(() => { + removals.delete(timeout) + setStore("items", (items) => items.filter((item) => item.id !== event.id)) + setStore( + "heights", + reconcile(Object.fromEntries(Object.entries(store.heights).filter(([id]) => Number(id) !== event.id))), + ) + }, REMOVE_DELAY) + removals.add(timeout) + return + } + + const index = store.items.findIndex((item) => item.id === event.item.id) + if (index === -1) { + setStore("items", (items) => [{ ...event.item, removed: false }, ...items]) + return + } + setStore("items", index, reconcile({ ...event.item, removed: false })) + }) + + const keydown = (event: KeyboardEvent) => { + if (event.altKey && event.code === "KeyT") { + setStore("expanded", true) + list?.focus() + } + if (event.code === "Escape" && list?.contains(document.activeElement)) setStore("expanded", false) + } + document.addEventListener("keydown", keydown) + + onCleanup(() => { + unsubscribe() + removals.forEach(clearTimeout) + document.removeEventListener("keydown", keydown) + }) + }) + + createEffect(() => { + if (store.items.length <= 1) setStore("expanded", false) + }) + + const visibleToasts = () => props.visibleToasts ?? 3 + const gap = () => props.gap ?? 12 + const frontHeight = () => store.heights[store.items[0]?.id] ?? 0 + + return ( +
+
    setStore("expanded", true)} + onMouseMove={() => setStore("expanded", true)} + onMouseLeave={() => { + if (!store.interacting) setStore("expanded", false) + }} + onFocusIn={() => setStore("expanded", true)} + onFocusOut={(event) => { + if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setStore("expanded", false) + }} + onPointerDown={() => setStore("interacting", true)} + onPointerUp={() => setStore("interacting", false)} + onPointerCancel={() => setStore("interacting", false)} + > + + {(item, index) => { + const offset = createMemo(() => + store.items + .slice(0, index()) + .reduce((total, toast) => total + (store.heights[toast.id] ?? frontHeight()), index() * gap()), + ) + return ( + setStore("heights", item.id, height)} + /> + ) + }} + +
+
+ ) +} + +function ToastV2StackToast(props: { + item: RenderedToastV2StackItem + index: number + offset: number + frontHeight: number + height: number + expanded: boolean + interacting: boolean + visible: boolean + duration?: number + onHeight: (height: number) => void +}) { + const [mounted, setMounted] = createSignal(false) + const [swiping, setSwiping] = createSignal(false) + const [swiped, setSwiped] = createSignal(false) + const [swipeOut, setSwipeOut] = createSignal(false) + const [swipeDirection, setSwipeDirection] = createSignal<"left" | "right" | "down" | undefined>() + const [hidden, setHidden] = createSignal(typeof document !== "undefined" && document.hidden) + let element: HTMLLIElement | undefined + let body: HTMLDivElement | undefined + let pointer: { x: number; y: number; time: number } | undefined + let remaining = props.item.duration ?? props.duration ?? DEFAULT_DURATION + let started = 0 + + onMount(() => { + setMounted(true) + if (!body) return + const measure = () => + props.onHeight(Math.min(Math.ceil(body!.getBoundingClientRect().height + 24), 420, window.innerHeight - 96)) + const observer = new ResizeObserver(measure) + observer.observe(body) + measure() + const visibility = () => setHidden(document.hidden) + document.addEventListener("visibilitychange", visibility) + onCleanup(() => { + observer.disconnect() + document.removeEventListener("visibilitychange", visibility) + }) + }) + + createEffect( + on( + () => props.item.revision, + (revision, previous) => { + remaining = props.item.duration ?? props.duration ?? DEFAULT_DURATION + if (!previous || !element || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return + element.animate([{ scale: 1 }, { scale: 1.025 }, { scale: 1 }], { duration: 160, easing: "ease-out" }) + }, + ), + ) + + createEffect(() => { + props.item.revision + const persistent = props.item.persistent || remaining === Number.POSITIVE_INFINITY + if (persistent) return + const paused = props.expanded || props.interacting || hidden() + if (paused) { + if (started) remaining -= Date.now() - started + started = 0 + return + } + started = Date.now() + const timeout = setTimeout(() => toastV2Stack.dismiss(props.item.id, true), remaining) + onCleanup(() => clearTimeout(timeout)) + }) + + const finishSwipe = () => { + if (!element || !pointer) return + const x = Number(element.style.getPropertyValue("--toast-v2-swipe-x").replace("px", "") || 0) + const y = Number(element.style.getPropertyValue("--toast-v2-swipe-y").replace("px", "") || 0) + const delta = Math.abs(x) > Math.abs(y) ? x : y + const velocity = Math.abs(delta) / Math.max(1, Date.now() - pointer.time) + pointer = undefined + if (Math.abs(delta) >= 45 || velocity > 0.11) { + setSwipeDirection(Math.abs(x) > Math.abs(y) ? (x > 0 ? "right" : "left") : "down") + setSwipeOut(true) + toastV2Stack.dismiss(props.item.id) + return + } + element.style.setProperty("--toast-v2-swipe-x", "0px") + element.style.setProperty("--toast-v2-swipe-y", "0px") + setSwiping(false) + setSwiped(false) + } + + return ( +
  • { + if (event.button === 2 || (event.target as HTMLElement).closest("button")) return + pointer = { x: event.clientX, y: event.clientY, time: Date.now() } + event.currentTarget.setPointerCapture(event.pointerId) + setSwiping(true) + }} + onPointerMove={(event) => { + if (!pointer || !element) return + const x = event.clientX - pointer.x + const y = Math.max(0, event.clientY - pointer.y) + element.style.setProperty("--toast-v2-swipe-x", `${x}px`) + element.style.setProperty("--toast-v2-swipe-y", `${y}px`) + setSwiped(Math.abs(x) > 1 || y > 1) + }} + onPointerUp={finishSwipe} + onPointerCancel={finishSwipe} + > +
    + {props.item.render()} +
    +
  • + ) +} diff --git a/packages/ui/src/v2/components/toast-v2.css b/packages/ui/src/v2/components/toast-v2.css index 5bce87dbdc7c..49277e4ea088 100644 --- a/packages/ui/src/v2/components/toast-v2.css +++ b/packages/ui/src/v2/components/toast-v2.css @@ -1,58 +1,121 @@ [data-component="toast-v2-region"] { position: fixed; - bottom: 48px; - right: 32px; + right: var(--toast-v2-offset-right); + bottom: var(--toast-v2-offset-bottom); z-index: 1000; - display: flex; - flex-direction: column; - gap: 12px; + width: 320px; max-width: min(320px, calc(100vw - 64px)); - max-height: none; - width: 100%; - overflow: visible; + height: 0; + padding: 0; + margin: 0; + list-style: none; + outline: 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; + --toast-v2-y: translateY(100%); + position: absolute; + right: 0; + bottom: 0; + z-index: var(--toast-v2-z-index); display: flex; flex-direction: column; - gap: 12px; - width: 320px; + width: 100%; padding: 12px; + height: auto; max-height: min(420px, calc(100dvh - 96px)); overflow: hidden; pointer-events: auto; - transition: transform 140ms ease-out; + opacity: 0; + transform: var(--toast-v2-y) translateX(var(--toast-v2-swipe-x, 0px)) translateY(var(--toast-v2-swipe-y, 0px)); + 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; + touch-action: none; + 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); - &[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-mounted="true"] { + --toast-v2-y: translateY(0); + opacity: 1; + } + + &[data-mounted="true"][data-expanded="false"][data-front="false"] { + --toast-v2-y: translateY(calc(-1 * var(--toast-v2-index) * var(--toast-v2-stack-gap))) + scale(calc(1 - var(--toast-v2-index) * 0.05)); + height: var(--toast-v2-height); + } + + &[data-mounted="true"][data-expanded="true"] { + --toast-v2-y: translateY(calc(-1 * var(--toast-v2-offset))); + height: var(--toast-v2-initial-height); + overflow: visible; + } + + &[data-expanded="true"]::after { + content: ""; + position: absolute; + left: 0; + bottom: 100%; + width: 100%; + height: calc(var(--toast-v2-gap) + 1px); } - &[data-closed] { - animation: toastV2PopOut 100ms ease-in forwards; + &[data-removed="true"] { + --toast-v2-y: translateY(100%); + opacity: 0; + } + + &[data-visible="false"] { + opacity: 0; + pointer-events: none; + } + + &[data-swiping="true"] { + transition: none; + } + + &[data-swipe-out="true"][data-swipe-direction="left"] { + animation: toastV2SwipeLeft 180ms ease-out forwards; + } + + &[data-swipe-out="true"][data-swipe-direction="right"] { + animation: toastV2SwipeRight 180ms ease-out forwards; + } + + &[data-swipe-out="true"][data-swipe-direction="down"] { + animation: toastV2SwipeDown 180ms ease-out forwards; + } + + &[data-expanded="false"][data-front="false"] [data-slot="toast-v2-body"] { + opacity: 0; + } + + [data-slot="toast-v2-body"] { + display: flex; + flex: none; + flex-direction: column; + gap: 12px; + width: 100%; + transition: opacity 160ms ease-out; } [data-slot="toast-v2-header"] { @@ -192,24 +255,40 @@ } } -@keyframes toastV2PopIn { - from { - opacity: 0.8; - transform: translateY(6px); - } +@keyframes toastV2SwipeLeft { to { - transform: translateY(0); - opacity: 1; + transform: var(--toast-v2-y) translateX(calc(var(--toast-v2-swipe-x) - 100%)); + opacity: 0; } } -@keyframes toastV2PopOut { - from { - transform: translateY(0); - opacity: 1; +@keyframes toastV2SwipeRight { + to { + transform: var(--toast-v2-y) translateX(calc(var(--toast-v2-swipe-x) + 100%)); + opacity: 0; } +} + +@keyframes toastV2SwipeDown { to { - transform: translateY(6px); - opacity: 0.8; + transform: var(--toast-v2-y) translateY(calc(var(--toast-v2-swipe-y) + 100%)); + opacity: 0; + } +} + +@media (prefers-reduced-motion: reduce) { + [data-component="toast-v2"], + [data-slot="toast-v2-body"] { + animation: none !important; + transition: none !important; + } +} + +@media (max-width: 600px) { + [data-component="toast-v2-region"] { + right: var(--toast-v2-mobile-offset); + bottom: var(--toast-v2-mobile-offset); + width: calc(100vw - var(--toast-v2-mobile-offset) * 2); + max-width: none; } } diff --git a/packages/ui/src/v2/components/toast-v2.stories.tsx b/packages/ui/src/v2/components/toast-v2.stories.tsx index f096459739f7..b2af8c038dde 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 d2dc1b3605f5..c83cda353821 100644 --- a/packages/ui/src/v2/components/toast-v2.tsx +++ b/packages/ui/src/v2/components/toast-v2.tsx @@ -1,39 +1,43 @@ -import { Toast as Kobalte, toaster } from "@kobalte/core/toast" -import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast" import type { ComponentProps, JSX } from "solid-js" -import { Show, children } from "solid-js" +import { Show, children, createContext, splitProps, useContext } from "solid-js" import { Portal } from "solid-js/web" import { ButtonV2 } from "./button-v2" +import { + ToastV2StackRegion, + ToastV2StackRenderContext, + toastV2Stack, + type ToastV2StackRegionProps, +} from "./toast-v2-stack" import "./toast-v2.css" -export interface ToastV2RegionProps extends ComponentProps {} +export interface ToastV2RegionProps extends ToastV2StackRegionProps {} function ToastV2Region(props: ToastV2RegionProps) { return ( - - - + ) } -export interface ToastV2RootComponentProps extends ToastRootProps { - class?: string - classList?: ComponentProps<"li">["classList"] - children?: JSX.Element +const ToastV2Context = createContext() + +export interface ToastV2RootComponentProps extends ComponentProps<"div"> { + toastId: number + duration?: number + persistent?: boolean } function ToastV2Root(props: ToastV2RootComponentProps) { + const [local, rest] = splitProps(props, ["toastId", "duration", "persistent", "children"]) + const stacked = useContext(ToastV2StackRenderContext) + if (stacked) return {local.children} return ( - + +
    + {local.children} +
    +
    ) } @@ -45,26 +49,46 @@ 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 ( - - - + ) } @@ -78,7 +102,33 @@ 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) { + const toastId = --toastV2Id + toastV2Stack.show({ id: toastId, revision: 1, render: () => render({ toastId }) }) + return toastId + }, + dismiss(toastId?: number) { + if (toastId === undefined) { + activeToastV2ByKey.clear() + activeToastV2ById.clear() + } else { + releaseToastV2(activeToastV2ById.get(toastId)) + } + return toastV2Stack.dismiss(toastId) + }, +} + +interface ActiveToastV2 { + id: number + key: string + revision: number +} + +const activeToastV2ByKey = new Map() +const activeToastV2ById = new Map() export interface ToastV2Action { label: string @@ -90,55 +140,99 @@ export interface ToastV2Options { title?: string description?: string icon?: JSX.Element + variant?: "default" | "success" | "error" | "loading" duration?: number persistent?: boolean actions?: ToastV2Action[] } 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} + 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 = toastV2Stack.getToasts() + + if (active && toasts[0]?.id === active.id) { + active.revision++ + publishToastV2(active, opts) + return active.id + } + + if (active && toasts.some((item) => item.id === active.id)) toasterV2.dismiss(active.id) + releaseToastV2(active) + + const entry = { id: --toastV2Id, key, revision: 1 } + activeToastV2ByKey.set(key, entry) + activeToastV2ById.set(entry.id, entry) + publishToastV2(entry, opts) + return entry.id +} + +function publishToastV2(entry: ActiveToastV2, opts: ToastV2Options) { + toastV2Stack.show({ + id: entry.id, + revision: entry.revision, + variant: opts.variant, + duration: opts.duration, + persistent: opts.persistent, + render: () => { + const resolvedIcon = children(() => opts.icon) + const icon = resolvedIcon() + const renderedIcon = typeof Node !== "undefined" && icon instanceof Node ? icon.cloneNode(true) : icon + return ( + +
    + + {renderedIcon} - - -
    - - - {opts.actions!.map((action) => ( - { - if (typeof action.onClick === "function") { - action.onClick() - } - toaster.dismiss(props.toastId) - }} - > - {action.label} - - ))} - - - - ) + + + {opts.title} + + + {opts.description} + + + +
    + + + {opts.actions!.map((action) => ( + { + if (typeof action.onClick === "function") action.onClick() + toasterV2.dismiss(entry.id) + }} + > + {action.label} + + ))} + + + + ) + }, + onDismiss: () => releaseToastV2(entry), + onAutoClose: () => releaseToastV2(entry), }) } +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 { loading?: JSX.Element success?: (data: T) => JSX.Element From 1c6219fc7bfcb70344e27be4a9f970519ca7673a Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:56:11 +0530 Subject: [PATCH 2/5] consolidate license --- packages/ui/LICENSE.sonner | 22 --------------- packages/ui/package.json | 1 - .../ui/src/v2/components/toast-v2-stack.tsx | 27 ++++++++++++++++++- 3 files changed, 26 insertions(+), 24 deletions(-) delete mode 100644 packages/ui/LICENSE.sonner diff --git a/packages/ui/LICENSE.sonner b/packages/ui/LICENSE.sonner deleted file mode 100644 index c4328e116da9..000000000000 --- a/packages/ui/LICENSE.sonner +++ /dev/null @@ -1,22 +0,0 @@ -MIT License - -Copyright (c) 2023 Emil Kowalski -Copyright (c) 2022 Robert Soriano - -Permission is hereby granted, free of charge, to any person obtaining a copy -of this software and associated documentation files (the "Software"), to deal -in the Software without restriction, including without limitation the rights -to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is -furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all -copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR -IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, -FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE -AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER -LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, -OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE -SOFTWARE. diff --git a/packages/ui/package.json b/packages/ui/package.json index 7c0eac21bab5..b8e3ec7172f9 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -30,7 +30,6 @@ "src/assets/icons/app/*", "src/assets/fonts/*", "src/assets/audio/*", - "LICENSE.sonner", "LICENSE" ], "exports": { diff --git a/packages/ui/src/v2/components/toast-v2-stack.tsx b/packages/ui/src/v2/components/toast-v2-stack.tsx index bfbdc589474d..0078800301dd 100644 --- a/packages/ui/src/v2/components/toast-v2-stack.tsx +++ b/packages/ui/src/v2/components/toast-v2-stack.tsx @@ -1,4 +1,29 @@ -// Stack behavior adapted from Sonner and solid-sonner. See LICENSE.sonner. +/* + * Stack behavior adapted from Sonner and solid-sonner. + * + * MIT License + * + * Copyright (c) 2023 Emil Kowalski + * Copyright (c) 2022 Robert Soriano + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + */ import type { JSX } from "solid-js" import { For, createContext, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" import { createStore, reconcile } from "solid-js/store" From 29bffa2cf97752d5f102e1315e028978a09959a8 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:24:41 +0530 Subject: [PATCH 3/5] cleanup --- packages/app/src/pages/layout.tsx | 9 +- packages/app/src/utils/toast.tsx | 17 +- packages/app/test-browser/toast-owner.test.ts | 7 +- .../ui/src/v2/components/toast-v2-stack.tsx | 35 ++-- packages/ui/src/v2/components/toast-v2.tsx | 153 ++++++++++-------- 5 files changed, 125 insertions(+), 96 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 812a479b200f..18e266af4f6d 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 { clearWorkspaceTerminals } from "@/context/terminal" import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache" @@ -382,7 +381,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) } @@ -1443,7 +1442,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: Session[] = await serverSDK() .client.session.list({ directory }) @@ -2437,7 +2436,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 e44454850823..6f23b63d1e83 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 4ed848bc9972..25ba5c000e11 100644 --- a/packages/app/test-browser/toast-owner.test.ts +++ b/packages/app/test-browser/toast-owner.test.ts @@ -1,8 +1,13 @@ -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" }) diff --git a/packages/ui/src/v2/components/toast-v2-stack.tsx b/packages/ui/src/v2/components/toast-v2-stack.tsx index 0078800301dd..62988b563472 100644 --- a/packages/ui/src/v2/components/toast-v2-stack.tsx +++ b/packages/ui/src/v2/components/toast-v2-stack.tsx @@ -25,10 +25,10 @@ * SOFTWARE. */ import type { JSX } from "solid-js" -import { For, createContext, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { For, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" import { createStore, reconcile } from "solid-js/store" -const DEFAULT_DURATION = 4000 +const DEFAULT_DURATION = 5000 const REMOVE_DELAY = 180 export interface ToastV2StackItem { @@ -51,8 +51,6 @@ type StackEvent = { type: "upsert"; item: ToastV2StackItem } | { type: "dismiss" const listeners = new Set<(event: StackEvent) => void>() let active: ToastV2StackItem[] = [] -export const ToastV2StackRenderContext = createContext(false) - export const toastV2Stack = { show(item: ToastV2StackItem) { const index = active.findIndex((toast) => toast.id === item.id) @@ -64,9 +62,8 @@ export const toastV2Stack = { const dismissed = id === undefined ? active : active.filter((toast) => toast.id === id) active = id === undefined ? [] : active.filter((toast) => toast.id !== id) dismissed.forEach((toast) => (auto ? toast.onAutoClose?.() : toast.onDismiss?.())) - if (id === undefined) - dismissed.forEach((toast) => listeners.forEach((listener) => listener({ type: "dismiss", id: toast.id }))) - else listeners.forEach((listener) => listener({ type: "dismiss", id })) + // Already-dismissed ids must not notify again; the removal timeout is still pending. + dismissed.forEach((toast) => listeners.forEach((listener) => listener({ type: "dismiss", id: toast.id }))) return id }, getToasts() { @@ -80,7 +77,6 @@ export const toastV2Stack = { export interface ToastV2StackRegionProps { class?: string - className?: string duration?: number gap?: number visibleToasts?: number @@ -164,14 +160,13 @@ export function ToastV2StackRegion(props: ToastV2StackRegionProps) {
      setStore("expanded", true)} onFocusOut={(event) => { - if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setStore("expanded", false) + const next = event.relatedTarget instanceof Node ? event.relatedTarget : null + if (!event.currentTarget.contains(next)) setStore("expanded", false) }} onPointerDown={() => setStore("interacting", true)} onPointerUp={() => setStore("interacting", false)} @@ -244,11 +240,12 @@ function ToastV2StackToast(props: { onMount(() => { setMounted(true) - if (!body) return + const node = body + if (!node) return const measure = () => - props.onHeight(Math.min(Math.ceil(body!.getBoundingClientRect().height + 24), 420, window.innerHeight - 96)) + props.onHeight(Math.min(Math.ceil(node.getBoundingClientRect().height + 24), 420, window.innerHeight - 96)) const observer = new ResizeObserver(measure) - observer.observe(body) + observer.observe(node) measure() const visibility = () => setHidden(document.hidden) document.addEventListener("visibilitychange", visibility) @@ -306,7 +303,8 @@ function ToastV2StackToast(props: { return (
    1. { - if (event.button === 2 || (event.target as HTMLElement).closest("button")) return + if (event.button === 2) return + if (event.target instanceof Element && event.target.closest("button")) return pointer = { x: event.clientX, y: event.clientY, time: Date.now() } event.currentTarget.setPointerCapture(event.pointerId) setSwiping(true) @@ -344,8 +343,8 @@ function ToastV2StackToast(props: { onPointerUp={finishSwipe} onPointerCancel={finishSwipe} > -
      - {props.item.render()} +
      + {props.item.render()}
    2. ) diff --git a/packages/ui/src/v2/components/toast-v2.tsx b/packages/ui/src/v2/components/toast-v2.tsx index c83cda353821..c08fd6b3c4a7 100644 --- a/packages/ui/src/v2/components/toast-v2.tsx +++ b/packages/ui/src/v2/components/toast-v2.tsx @@ -2,12 +2,7 @@ import type { ComponentProps, JSX } from "solid-js" import { Show, children, createContext, splitProps, useContext } from "solid-js" import { Portal } from "solid-js/web" import { ButtonV2 } from "./button-v2" -import { - ToastV2StackRegion, - ToastV2StackRenderContext, - toastV2Stack, - type ToastV2StackRegionProps, -} from "./toast-v2-stack" +import { ToastV2StackRegion, toastV2Stack, type ToastV2StackRegionProps } from "./toast-v2-stack" import "./toast-v2.css" export interface ToastV2RegionProps extends ToastV2StackRegionProps {} @@ -22,23 +17,15 @@ function ToastV2Region(props: ToastV2RegionProps) { const ToastV2Context = createContext() -export interface ToastV2RootComponentProps extends ComponentProps<"div"> { +// The stack owns the toast card element, so the root only scopes the toast id for +// subcomponents. Lifetime lives on `toasterV2.show` and `showToastV2`. +export interface ToastV2RootComponentProps { toastId: number - duration?: number - persistent?: boolean + children?: JSX.Element } function ToastV2Root(props: ToastV2RootComponentProps) { - const [local, rest] = splitProps(props, ["toastId", "duration", "persistent", "children"]) - const stacked = useContext(ToastV2StackRenderContext) - if (stacked) return {local.children} - return ( - -
      - {local.children} -
      -
      - ) + return {props.children} } function ToastV2Icon(props: ComponentProps<"div">) { @@ -105,9 +92,15 @@ export const ToastV2 = Object.assign(ToastV2Root, { let toastV2Id = 0 export const toasterV2 = { - show(render: (props: { toastId: number }) => JSX.Element) { + show(render: (props: { toastId: number }) => JSX.Element, options?: { duration?: number; persistent?: boolean }) { const toastId = --toastV2Id - toastV2Stack.show({ id: toastId, revision: 1, render: () => render({ toastId }) }) + toastV2Stack.show({ + id: toastId, + revision: 1, + duration: options?.duration, + persistent: options?.persistent, + render: () => render({ toastId }), + }) return toastId }, dismiss(toastId?: number) { @@ -125,6 +118,11 @@ interface ActiveToastV2 { id: number key: string revision: number + options: ToastV2Options + // Stable across repeats so coalescing only bumps `revision` instead of + // replacing the rendered card, which would drop focus inside it. + render: () => JSX.Element + release: () => void } const activeToastV2ByKey = new Map() @@ -160,73 +158,88 @@ export function showToastV2(options: ToastV2Options | string) { const toasts = toastV2Stack.getToasts() if (active && toasts[0]?.id === active.id) { + active.options = opts active.revision++ - publishToastV2(active, opts) + publishToastV2(active) return active.id } if (active && toasts.some((item) => item.id === active.id)) toasterV2.dismiss(active.id) releaseToastV2(active) - const entry = { id: --toastV2Id, key, revision: 1 } + const entry: ActiveToastV2 = { + id: --toastV2Id, + key, + revision: 1, + options: opts, + render: () => renderToastV2(entry), + release: () => releaseToastV2(entry), + } activeToastV2ByKey.set(key, entry) activeToastV2ById.set(entry.id, entry) - publishToastV2(entry, opts) + publishToastV2(entry) return entry.id } -function publishToastV2(entry: ActiveToastV2, opts: ToastV2Options) { +function publishToastV2(entry: ActiveToastV2) { + // Everything except `revision` is identical whenever an existing toast is reused, + // because the dedupe key covers variant, duration, persistence, and action labels. toastV2Stack.show({ id: entry.id, revision: entry.revision, - variant: opts.variant, - duration: opts.duration, - persistent: opts.persistent, - render: () => { - const resolvedIcon = children(() => opts.icon) - const icon = resolvedIcon() - const renderedIcon = typeof Node !== "undefined" && icon instanceof Node ? icon.cloneNode(true) : icon - return ( - -
      - - {renderedIcon} - - - - {opts.title} - - - {opts.description} - - - -
      - - - {opts.actions!.map((action) => ( - { - if (typeof action.onClick === "function") action.onClick() - toasterV2.dismiss(entry.id) - }} - > - {action.label} - - ))} - - -
      - ) - }, - onDismiss: () => releaseToastV2(entry), - onAutoClose: () => releaseToastV2(entry), + variant: entry.options.variant, + duration: entry.options.duration, + persistent: entry.options.persistent, + render: entry.render, + onDismiss: entry.release, + onAutoClose: entry.release, }) } +function renderToastV2(entry: ActiveToastV2) { + const opts = entry.options + const resolvedIcon = children(() => opts.icon) + const icon = resolvedIcon() + // A caller-provided element is a live node, so reuse it only through a copy; + // the region can render an item again after remounting. + const renderedIcon = typeof Node !== "undefined" && icon instanceof Node ? icon.cloneNode(true) : icon + return ( + +
      + + {renderedIcon} + + + + {opts.title} + + + {opts.description} + + + +
      + + + {opts.actions!.map((action) => ( + { + if (typeof action.onClick === "function") action.onClick() + toasterV2.dismiss(entry.id) + }} + > + {action.label} + + ))} + + +
      + ) +} + function releaseToastV2(entry: ActiveToastV2 | undefined) { if (!entry) return if (activeToastV2ByKey.get(entry.key) === entry) activeToastV2ByKey.delete(entry.key) From 06328fcd74c1b69937b66b172b90ed5f7f9294ee Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:42:22 +0530 Subject: [PATCH 4/5] use solid-sonner instead of a custom port --- bun.lock | 4 + package.json | 1 + packages/ui/package.json | 1 + .../ui/src/v2/components/toast-v2-stack.tsx | 351 ------------------ packages/ui/src/v2/components/toast-v2.css | 293 ++++++--------- packages/ui/src/v2/components/toast-v2.tsx | 225 ++++++----- 6 files changed, 242 insertions(+), 633 deletions(-) delete mode 100644 packages/ui/src/v2/components/toast-v2-stack.tsx diff --git a/bun.lock b/bun.lock index ccca966458c8..ee88a50b40ec 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": { @@ -1145,6 +1146,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", @@ -5126,6 +5128,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 5fd0f1d51ad8..ee1506191730 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/ui/package.json b/packages/ui/package.json index b8e3ec7172f9..6a09c33ded44 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-stack.tsx b/packages/ui/src/v2/components/toast-v2-stack.tsx deleted file mode 100644 index 62988b563472..000000000000 --- a/packages/ui/src/v2/components/toast-v2-stack.tsx +++ /dev/null @@ -1,351 +0,0 @@ -/* - * Stack behavior adapted from Sonner and solid-sonner. - * - * MIT License - * - * Copyright (c) 2023 Emil Kowalski - * Copyright (c) 2022 Robert Soriano - * - * Permission is hereby granted, free of charge, to any person obtaining a copy - * of this software and associated documentation files (the "Software"), to deal - * in the Software without restriction, including without limitation the rights - * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell - * copies of the Software, and to permit persons to whom the Software is - * furnished to do so, subject to the following conditions: - * - * The above copyright notice and this permission notice shall be included in all - * copies or substantial portions of the Software. - * - * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR - * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, - * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, - * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE - * SOFTWARE. - */ -import type { JSX } from "solid-js" -import { For, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" -import { createStore, reconcile } from "solid-js/store" - -const DEFAULT_DURATION = 5000 -const REMOVE_DELAY = 180 - -export interface ToastV2StackItem { - id: number - revision: number - variant?: string - duration?: number - persistent?: boolean - render: () => JSX.Element - onDismiss?: () => void - onAutoClose?: () => void -} - -interface RenderedToastV2StackItem extends ToastV2StackItem { - removed: boolean -} - -type StackEvent = { type: "upsert"; item: ToastV2StackItem } | { type: "dismiss"; id: number } - -const listeners = new Set<(event: StackEvent) => void>() -let active: ToastV2StackItem[] = [] - -export const toastV2Stack = { - show(item: ToastV2StackItem) { - const index = active.findIndex((toast) => toast.id === item.id) - active = index === -1 ? [item, ...active] : active.map((toast) => (toast.id === item.id ? item : toast)) - listeners.forEach((listener) => listener({ type: "upsert", item })) - return item.id - }, - dismiss(id?: number, auto = false) { - const dismissed = id === undefined ? active : active.filter((toast) => toast.id === id) - active = id === undefined ? [] : active.filter((toast) => toast.id !== id) - dismissed.forEach((toast) => (auto ? toast.onAutoClose?.() : toast.onDismiss?.())) - // Already-dismissed ids must not notify again; the removal timeout is still pending. - dismissed.forEach((toast) => listeners.forEach((listener) => listener({ type: "dismiss", id: toast.id }))) - return id - }, - getToasts() { - return active - }, - subscribe(listener: (event: StackEvent) => void) { - listeners.add(listener) - return () => listeners.delete(listener) - }, -} - -export interface ToastV2StackRegionProps { - class?: string - duration?: number - gap?: number - visibleToasts?: number - offset?: { right?: number; bottom?: number } - mobileOffset?: number - containerAriaLabel?: string -} - -export function ToastV2StackRegion(props: ToastV2StackRegionProps) { - const [store, setStore] = createStore<{ - items: RenderedToastV2StackItem[] - heights: Record - expanded: boolean - interacting: boolean - }>({ - items: toastV2Stack.getToasts().map((item) => ({ ...item, removed: false }) satisfies RenderedToastV2StackItem), - heights: {} as Record, - expanded: false, - interacting: false, - }) - const removals = new Set>() - let list: HTMLOListElement | undefined - - onMount(() => { - const unsubscribe = toastV2Stack.subscribe((event) => { - if (event.type === "dismiss") { - const index = store.items.findIndex((item) => item.id === event.id) - if (index === -1) return - setStore("items", index, "removed", true) - const timeout = setTimeout(() => { - removals.delete(timeout) - setStore("items", (items) => items.filter((item) => item.id !== event.id)) - setStore( - "heights", - reconcile(Object.fromEntries(Object.entries(store.heights).filter(([id]) => Number(id) !== event.id))), - ) - }, REMOVE_DELAY) - removals.add(timeout) - return - } - - const index = store.items.findIndex((item) => item.id === event.item.id) - if (index === -1) { - setStore("items", (items) => [{ ...event.item, removed: false }, ...items]) - return - } - setStore("items", index, reconcile({ ...event.item, removed: false })) - }) - - const keydown = (event: KeyboardEvent) => { - if (event.altKey && event.code === "KeyT") { - setStore("expanded", true) - list?.focus() - } - if (event.code === "Escape" && list?.contains(document.activeElement)) setStore("expanded", false) - } - document.addEventListener("keydown", keydown) - - onCleanup(() => { - unsubscribe() - removals.forEach(clearTimeout) - document.removeEventListener("keydown", keydown) - }) - }) - - createEffect(() => { - if (store.items.length <= 1) setStore("expanded", false) - }) - - const visibleToasts = () => props.visibleToasts ?? 3 - const gap = () => props.gap ?? 12 - const frontHeight = () => store.heights[store.items[0]?.id] ?? 0 - - return ( -
      -
        setStore("expanded", true)} - onMouseMove={() => setStore("expanded", true)} - onMouseLeave={() => { - if (!store.interacting) setStore("expanded", false) - }} - onFocusIn={() => setStore("expanded", true)} - onFocusOut={(event) => { - const next = event.relatedTarget instanceof Node ? event.relatedTarget : null - if (!event.currentTarget.contains(next)) setStore("expanded", false) - }} - onPointerDown={() => setStore("interacting", true)} - onPointerUp={() => setStore("interacting", false)} - onPointerCancel={() => setStore("interacting", false)} - > - - {(item, index) => { - const offset = createMemo(() => - store.items - .slice(0, index()) - .reduce((total, toast) => total + (store.heights[toast.id] ?? frontHeight()), index() * gap()), - ) - return ( - setStore("heights", item.id, height)} - /> - ) - }} - -
      -
      - ) -} - -function ToastV2StackToast(props: { - item: RenderedToastV2StackItem - index: number - offset: number - frontHeight: number - height: number - expanded: boolean - interacting: boolean - visible: boolean - duration?: number - onHeight: (height: number) => void -}) { - const [mounted, setMounted] = createSignal(false) - const [swiping, setSwiping] = createSignal(false) - const [swiped, setSwiped] = createSignal(false) - const [swipeOut, setSwipeOut] = createSignal(false) - const [swipeDirection, setSwipeDirection] = createSignal<"left" | "right" | "down" | undefined>() - const [hidden, setHidden] = createSignal(typeof document !== "undefined" && document.hidden) - let element: HTMLLIElement | undefined - let body: HTMLDivElement | undefined - let pointer: { x: number; y: number; time: number } | undefined - let remaining = props.item.duration ?? props.duration ?? DEFAULT_DURATION - let started = 0 - - onMount(() => { - setMounted(true) - const node = body - if (!node) return - const measure = () => - props.onHeight(Math.min(Math.ceil(node.getBoundingClientRect().height + 24), 420, window.innerHeight - 96)) - const observer = new ResizeObserver(measure) - observer.observe(node) - measure() - const visibility = () => setHidden(document.hidden) - document.addEventListener("visibilitychange", visibility) - onCleanup(() => { - observer.disconnect() - document.removeEventListener("visibilitychange", visibility) - }) - }) - - createEffect( - on( - () => props.item.revision, - (revision, previous) => { - remaining = props.item.duration ?? props.duration ?? DEFAULT_DURATION - if (!previous || !element || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return - element.animate([{ scale: 1 }, { scale: 1.025 }, { scale: 1 }], { duration: 160, easing: "ease-out" }) - }, - ), - ) - - createEffect(() => { - props.item.revision - const persistent = props.item.persistent || remaining === Number.POSITIVE_INFINITY - if (persistent) return - const paused = props.expanded || props.interacting || hidden() - if (paused) { - if (started) remaining -= Date.now() - started - started = 0 - return - } - started = Date.now() - const timeout = setTimeout(() => toastV2Stack.dismiss(props.item.id, true), remaining) - onCleanup(() => clearTimeout(timeout)) - }) - - const finishSwipe = () => { - if (!element || !pointer) return - const x = Number(element.style.getPropertyValue("--toast-v2-swipe-x").replace("px", "") || 0) - const y = Number(element.style.getPropertyValue("--toast-v2-swipe-y").replace("px", "") || 0) - const delta = Math.abs(x) > Math.abs(y) ? x : y - const velocity = Math.abs(delta) / Math.max(1, Date.now() - pointer.time) - pointer = undefined - if (Math.abs(delta) >= 45 || velocity > 0.11) { - setSwipeDirection(Math.abs(x) > Math.abs(y) ? (x > 0 ? "right" : "left") : "down") - setSwipeOut(true) - toastV2Stack.dismiss(props.item.id) - return - } - element.style.setProperty("--toast-v2-swipe-x", "0px") - element.style.setProperty("--toast-v2-swipe-y", "0px") - setSwiping(false) - setSwiped(false) - } - - return ( -
    3. { - if (event.button === 2) return - if (event.target instanceof Element && event.target.closest("button")) return - pointer = { x: event.clientX, y: event.clientY, time: Date.now() } - event.currentTarget.setPointerCapture(event.pointerId) - setSwiping(true) - }} - onPointerMove={(event) => { - if (!pointer || !element) return - const x = event.clientX - pointer.x - const y = Math.max(0, event.clientY - pointer.y) - element.style.setProperty("--toast-v2-swipe-x", `${x}px`) - element.style.setProperty("--toast-v2-swipe-y", `${y}px`) - setSwiped(Math.abs(x) > 1 || y > 1) - }} - onPointerUp={finishSwipe} - onPointerCancel={finishSwipe} - > -
      - {props.item.render()} -
      -
    4. - ) -} diff --git a/packages/ui/src/v2/components/toast-v2.css b/packages/ui/src/v2/components/toast-v2.css index 49277e4ea088..db173489a9f5 100644 --- a/packages/ui/src/v2/components/toast-v2.css +++ b/packages/ui/src/v2/components/toast-v2.css @@ -1,51 +1,39 @@ -[data-component="toast-v2-region"] { - position: fixed; - right: var(--toast-v2-offset-right); - bottom: var(--toast-v2-offset-bottom); +.toast-v2-region { z-index: 1000; - width: 320px; max-width: min(320px, calc(100vw - 64px)); - height: 0; - padding: 0; - margin: 0; - list-style: none; - outline: none; pointer-events: none; user-select: none; -webkit-user-select: none; } -[data-component="toast-v2"] { - --toast-v2-y: translateY(100%); - position: absolute; - right: 0; - bottom: 0; - z-index: var(--toast-v2-z-index); - display: flex; - flex-direction: column; - width: 100%; +.toast-v2 { + display: grid; + grid-template-columns: minmax(0, 1fr) 20px; + column-gap: 12px; + row-gap: 12px; + width: 320px; + max-width: 100%; padding: 12px; - height: auto; max-height: min(420px, calc(100dvh - 96px)); overflow: hidden; pointer-events: auto; - opacity: 0; - transform: var(--toast-v2-y) translateX(var(--toast-v2-swipe-x, 0px)) translateY(var(--toast-v2-swipe-y, 0px)); - 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; - touch-action: none; 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; + + &:has(> [data-icon]) { + grid-template-columns: 16px minmax(0, 1fr) 20px; + } &:focus-visible { box-shadow: @@ -53,125 +41,93 @@ 0 0 0 2px var(--v2-border-border-focus); } - &[data-mounted="true"] { - --toast-v2-y: translateY(0); - opacity: 1; - } - - &[data-mounted="true"][data-expanded="false"][data-front="false"] { - --toast-v2-y: translateY(calc(-1 * var(--toast-v2-index) * var(--toast-v2-stack-gap))) - scale(calc(1 - var(--toast-v2-index) * 0.05)); - height: var(--toast-v2-height); - } - - &[data-mounted="true"][data-expanded="true"] { - --toast-v2-y: translateY(calc(-1 * var(--toast-v2-offset))); - height: var(--toast-v2-initial-height); + &[data-expanded="true"] { overflow: visible; } - &[data-expanded="true"]::after { - content: ""; - position: absolute; - left: 0; - bottom: 100%; - width: 100%; - height: calc(var(--toast-v2-gap) + 1px); - } - - &[data-removed="true"] { - --toast-v2-y: translateY(100%); + &[data-expanded="false"][data-front="false"] > * { opacity: 0; } - &[data-visible="false"] { - opacity: 0; - pointer-events: none; + &[data-expanded="false"][data-front="false"] { + --y: translateY(calc(-1 * var(--toasts-before) * 9.5px)) scale(calc(1 - var(--toasts-before) * 0.05)); } - &[data-swiping="true"] { - transition: none; + > [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-swipe-out="true"][data-swipe-direction="left"] { - animation: toastV2SwipeLeft 180ms ease-out forwards; + > [data-content] { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + overflow: hidden; } - &[data-swipe-out="true"][data-swipe-direction="right"] { - animation: toastV2SwipeRight 180ms ease-out forwards; + &:has(> [data-icon]) > [data-content] { + grid-column: 2; } - &[data-swipe-out="true"][data-swipe-direction="down"] { - animation: toastV2SwipeDown 180ms ease-out forwards; + > [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; } - &[data-expanded="false"][data-front="false"] [data-slot="toast-v2-body"] { - opacity: 0; + &:has(> [data-icon]) > [data-close-button] { + grid-column: 3; } - [data-slot="toast-v2-body"] { - display: flex; - flex: none; - flex-direction: column; - gap: 12px; - width: 100%; - transition: opacity 160ms ease-out; + > [data-close-button]:hover { + background: var(--v2-overlay-simple-overlay-hover); + color: var(--v2-icon-icon-base); } - [data-slot="toast-v2-header"] { - display: flex; - align-items: flex-start; - gap: 12px; - width: 100%; + > [data-close-button]:active { + background: var(--v2-overlay-simple-overlay-pressed); } - [data-slot="toast-v2-icon"] { - flex-shrink: 0; - display: flex; - align-items: center; - 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-close-button]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; } - [data-slot="toast-v2-content"] { - flex: 1; - display: flex; - flex-direction: column; - gap: 4px; - min-height: 0; - min-width: 0; - overflow: hidden; + > [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; @@ -186,6 +142,7 @@ margin: 0; } + [data-description], [data-slot="toast-v2-description"] { color: var(--v2-text-text-muted); text-wrap-style: pretty; @@ -201,10 +158,15 @@ } [data-slot="toast-v2-actions"] { + grid-column: 1; + grid-row: 2; display: flex; flex-wrap: wrap; 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"] { @@ -217,78 +179,47 @@ 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; - 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; 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; - } - } -} - -@keyframes toastV2SwipeLeft { - to { - transform: var(--toast-v2-y) translateX(calc(var(--toast-v2-swipe-x) - 100%)); - opacity: 0; - } -} - -@keyframes toastV2SwipeRight { - to { - transform: var(--toast-v2-y) translateX(calc(var(--toast-v2-swipe-x) + 100%)); - opacity: 0; + width: 16px; + height: 20px; + color: var(--v2-icon-icon-base); } -} -@keyframes toastV2SwipeDown { - to { - transform: var(--toast-v2-y) translateY(calc(var(--toast-v2-swipe-y) + 100%)); - opacity: 0; + [data-slot="toast-v2-content"] { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + overflow: hidden; } } @media (prefers-reduced-motion: reduce) { - [data-component="toast-v2"], - [data-slot="toast-v2-body"] { + .toast-v2, + .toast-v2 > * { animation: none !important; transition: none !important; } } @media (max-width: 600px) { - [data-component="toast-v2-region"] { - right: var(--toast-v2-mobile-offset); - bottom: var(--toast-v2-mobile-offset); - width: calc(100vw - var(--toast-v2-mobile-offset) * 2); - max-width: none; + .toast-v2-region { + max-width: calc(100vw - 32px); + } + + .toast-v2 { + width: 100%; } } diff --git a/packages/ui/src/v2/components/toast-v2.tsx b/packages/ui/src/v2/components/toast-v2.tsx index c08fd6b3c4a7..a7a922bcd04f 100644 --- a/packages/ui/src/v2/components/toast-v2.tsx +++ b/packages/ui/src/v2/components/toast-v2.tsx @@ -1,24 +1,70 @@ +import { Toaster, toast, type ToasterProps } from "solid-sonner" import type { ComponentProps, JSX } from "solid-js" -import { Show, children, createContext, splitProps, useContext } from "solid-js" +import { createContext, onCleanup, onMount, splitProps, useContext } from "solid-js" import { Portal } from "solid-js/web" -import { ButtonV2 } from "./button-v2" -import { ToastV2StackRegion, toastV2Stack, type ToastV2StackRegionProps } from "./toast-v2-stack" +import "./button-v2.css" import "./toast-v2.css" -export interface ToastV2RegionProps extends ToastV2StackRegionProps {} +export interface ToastV2RegionProps extends ToasterProps {} function ToastV2Region(props: ToastV2RegionProps) { + const [local, rest] = splitProps(props, ["class", "className", "style", "toastOptions"]) + 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 ( - + ) } const ToastV2Context = createContext() -// The stack owns the toast card element, so the root only scopes the toast id for -// subcomponents. Lifetime lives on `toasterV2.show` and `showToastV2`. export interface ToastV2RootComponentProps { toastId: number children?: JSX.Element @@ -62,23 +108,20 @@ function ToastV2CloseButton(props: ComponentProps<"button">) { if (!event.defaultPrevented && toastId !== undefined) toasterV2.dismiss(toastId) }} > - {local.children ?? ( - - )} + {local.children ?? } ) } +function CloseIcon() { + return ( + + ) +} + export const ToastV2 = Object.assign(ToastV2Root, { Region: ToastV2Region, Icon: ToastV2Icon, @@ -94,12 +137,11 @@ let toastV2Id = 0 export const toasterV2 = { show(render: (props: { toastId: number }) => JSX.Element, options?: { duration?: number; persistent?: boolean }) { const toastId = --toastV2Id - toastV2Stack.show({ + toast.custom((id) => render({ toastId: Number(id) }), { id: toastId, - revision: 1, - duration: options?.duration, - persistent: options?.persistent, - render: () => render({ toastId }), + className: "toast-v2", + duration: options?.persistent ? Number.POSITIVE_INFINITY : options?.duration, + unstyled: true, }) return toastId }, @@ -110,24 +152,10 @@ export const toasterV2 = { } else { releaseToastV2(activeToastV2ById.get(toastId)) } - return toastV2Stack.dismiss(toastId) + return toast.dismiss(toastId) }, } -interface ActiveToastV2 { - id: number - key: string - revision: number - options: ToastV2Options - // Stable across repeats so coalescing only bumps `revision` instead of - // replacing the rendered card, which would drop focus inside it. - render: () => JSX.Element - release: () => void -} - -const activeToastV2ByKey = new Map() -const activeToastV2ById = new Map() - export interface ToastV2Action { label: string variant?: "primary" | "secondary" @@ -144,6 +172,16 @@ export interface ToastV2Options { 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: ToastV2Options = typeof options === "string" ? { description: options } : options const key = JSON.stringify({ @@ -155,26 +193,20 @@ export function showToastV2(options: ToastV2Options | string) { actions: opts.actions?.map((action) => [action.label, action.variant]), }) const active = activeToastV2ByKey.get(key) - const toasts = toastV2Stack.getToasts() + const toasts = toast.getToasts() - if (active && toasts[0]?.id === active.id) { + if (active && toasts.at(-1)?.id === active.id) { active.options = opts - active.revision++ 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, - revision: 1, - options: opts, - render: () => renderToastV2(entry), - release: () => releaseToastV2(entry), - } + const entry: ActiveToastV2 = { id: --toastV2Id, key, options: opts } + entry.actions = createToastV2Actions(entry) activeToastV2ByKey.set(key, entry) activeToastV2ById.set(entry.id, entry) publishToastV2(entry) @@ -182,64 +214,55 @@ export function showToastV2(options: ToastV2Options | string) { } function publishToastV2(entry: ActiveToastV2) { - // Everything except `revision` is identical whenever an existing toast is reused, - // because the dedupe key covers variant, duration, persistence, and action labels. - toastV2Stack.show({ + const release = () => releaseToastV2(entry) + toast(entry.options.title ?? "", { id: entry.id, - revision: entry.revision, - variant: entry.options.variant, - duration: entry.options.duration, - persistent: entry.options.persistent, - render: entry.render, - onDismiss: entry.release, - onAutoClose: entry.release, + 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 renderToastV2(entry: ActiveToastV2) { - const opts = entry.options - const resolvedIcon = children(() => opts.icon) - const icon = resolvedIcon() - // A caller-provided element is a live node, so reuse it only through a copy; - // the region can render an item again after remounting. - const renderedIcon = typeof Node !== "undefined" && icon instanceof Node ? icon.cloneNode(true) : icon +function createToastV2Actions(entry: ActiveToastV2) { + if (!entry.options.actions?.length) return undefined return ( - -
      - - {renderedIcon} - - - - {opts.title} - - - {opts.description} - - - -
      - - - {opts.actions!.map((action) => ( - { - if (typeof action.onClick === "function") action.onClick() - toasterV2.dismiss(entry.id) - }} - > - {action.label} - - ))} - - -
      + + {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) From aaa81f8c1dac91f6a915337e2b18bc4e65935c72 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Wed, 29 Jul 2026 18:43:41 +0530 Subject: [PATCH 5/5] constrain dismissal to y axis --- packages/ui/src/v2/components/toast-v2.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/ui/src/v2/components/toast-v2.tsx b/packages/ui/src/v2/components/toast-v2.tsx index a7a922bcd04f..efb6ecd40cf8 100644 --- a/packages/ui/src/v2/components/toast-v2.tsx +++ b/packages/ui/src/v2/components/toast-v2.tsx @@ -8,7 +8,7 @@ import "./toast-v2.css" export interface ToastV2RegionProps extends ToasterProps {} function ToastV2Region(props: ToastV2RegionProps) { - const [local, rest] = splitProps(props, ["class", "className", "style", "toastOptions"]) + const [local, rest] = splitProps(props, ["class", "className", "style", "toastOptions", "swipeDirections"]) onMount(() => { const sync = () => { document.querySelectorAll(".toast-v2-region .toast-v2").forEach((element) => { @@ -49,6 +49,7 @@ function ToastV2Region(props: ToastV2RegionProps) { 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={{