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/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 104a50585165..25ba5c000e11 100644 --- a/packages/app/test-browser/toast-owner.test.ts +++ b/packages/app/test-browser/toast-owner.test.ts @@ -1,8 +1,46 @@ -import { describe, expect, test } from "bun:test" +import { beforeEach, describe, expect, test } from "bun:test" import { createSignal, type JSX } from "solid-js" import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2" describe("showToastV2", () => { + // The toast registry is module state, so each test starts from an empty stack. + beforeEach(() => { + toasterV2.dismiss() + }) + + test("coalesces exact active content", () => { + const first = showToastV2({ title: "Repeated error", description: "Try again" }) + const second = showToastV2({ title: "Repeated error", description: "Try again" }) + const different = showToastV2({ title: "Repeated error", description: "A different error" }) + + expect(second).toBe(first) + expect(different).not.toBe(first) + + toasterV2.dismiss(first) + toasterV2.dismiss(different) + }) + + test("allows dismissed content to appear again", () => { + const first = showToastV2("Dismiss and retry") + toasterV2.dismiss(first) + + const second = showToastV2("Dismiss and retry") + expect(second).not.toBe(first) + + toasterV2.dismiss(second) + }) + + test("recreates matching content when it is not the topmost toast", () => { + const first = showToastV2("First toast") + const topmost = showToastV2("Topmost toast") + const repeated = showToastV2("First toast") + + expect(repeated).not.toBe(first) + + toasterV2.dismiss(topmost) + toasterV2.dismiss(repeated) + }) + test("creates no reactive computations at call time", () => { const [tick, setTick] = createSignal(0) let reads = 0 diff --git a/packages/ui/package.json b/packages/ui/package.json index 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.css b/packages/ui/src/v2/components/toast-v2.css index 5bce87dbdc7c..db173489a9f5 100644 --- a/packages/ui/src/v2/components/toast-v2.css +++ b/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; - display: flex; - flex-direction: column; - gap: 12px; max-width: min(320px, calc(100vw - 64px)); - max-height: none; - width: 100%; - overflow: visible; pointer-events: none; - - [data-slot="toast-v2-list"] { - display: flex; - flex-direction: column; - gap: 12px; - list-style: none; - margin: 0; - padding: 0; - max-height: none; - overflow-y: visible; - overflow-x: visible; - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } - } + user-select: none; + -webkit-user-select: none; } -[data-component="toast-v2"] { - position: relative; - display: flex; - flex-direction: column; - gap: 12px; +.toast-v2 { + display: grid; + grid-template-columns: minmax(0, 1fr) 20px; + column-gap: 12px; + row-gap: 12px; width: 320px; + max-width: 100%; padding: 12px; max-height: min(420px, calc(100dvh - 96px)); overflow: hidden; pointer-events: auto; - transition: transform 140ms ease-out; - + box-sizing: border-box; + outline: none; + user-select: none; + -webkit-user-select: none; border-radius: 8px; color: var(--v2-text-text-base); background: var(--v2-background-bg-layer-01); box-shadow: var(--v2-elevation-floating); + transition: + transform 280ms cubic-bezier(0.2, 0, 0, 1), + opacity 160ms ease-out, + height 280ms cubic-bezier(0.2, 0, 0, 1), + box-shadow 160ms ease-out; + + &: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; 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-slot="toast-v2-content"] { - flex: 1; + > [data-content] { + grid-column: 1; + grid-row: 1; display: flex; flex-direction: column; gap: 4px; - min-height: 0; min-width: 0; overflow: hidden; } + &:has(> [data-icon]) > [data-content] { + grid-column: 2; + } + + > [data-close-button] { + grid-column: 2; + grid-row: 1; + position: static; + align-self: start; + width: 20px; + height: 20px; + min-width: 20px; + min-height: 20px; + padding: 0; + border: 0; + border-radius: 4px; + transform: none; + background: transparent; + color: var(--v2-icon-icon-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + } + + &:has(> [data-icon]) > [data-close-button] { + grid-column: 3; + } + + > [data-close-button]:hover { + background: var(--v2-overlay-simple-overlay-hover); + color: var(--v2-icon-icon-base); + } + + > [data-close-button]:active { + background: var(--v2-overlay-simple-overlay-pressed); + } + + > [data-close-button]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; + } + + > [data-icon] > *, + > [data-icon] svg, + > [data-close-button] svg { + width: 16px; + height: 16px; + display: block; + flex-shrink: 0; + } + + [data-title], [data-slot="toast-v2-title"] { color: var(--v2-text-text-base); overflow: hidden; @@ -123,6 +142,7 @@ margin: 0; } + [data-description], [data-slot="toast-v2-description"] { color: var(--v2-text-text-muted); text-wrap-style: pretty; @@ -138,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"] { @@ -154,62 +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; + 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%; } } 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..efb6ecd40cf8 100644 --- a/packages/ui/src/v2/components/toast-v2.tsx +++ b/packages/ui/src/v2/components/toast-v2.tsx @@ -1,40 +1,78 @@ -import { Toast as Kobalte, toaster } from "@kobalte/core/toast" -import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast" +import { Toaster, toast, type ToasterProps } from "solid-sonner" import type { ComponentProps, JSX } from "solid-js" -import { Show, children } from "solid-js" +import { createContext, onCleanup, onMount, splitProps, useContext } from "solid-js" import { Portal } from "solid-js/web" -import { ButtonV2 } from "./button-v2" +import "./button-v2.css" import "./toast-v2.css" -export interface ToastV2RegionProps extends ComponentProps {} +export interface ToastV2RegionProps extends ToasterProps {} function ToastV2Region(props: ToastV2RegionProps) { + const [local, rest] = splitProps(props, ["class", "className", "style", "toastOptions", "swipeDirections"]) + onMount(() => { + const sync = () => { + document.querySelectorAll(".toast-v2-region .toast-v2").forEach((element) => { + const hidden = element.dataset.visible === "false" + element.inert = hidden + element.tabIndex = hidden ? -1 : 0 + }) + } + let connected = false + const connect = () => { + const regions = document.querySelectorAll(".toast-v2-region") + if (!regions.length) return + observer.disconnect() + regions.forEach((region) => { + observer.observe(region, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["data-visible"], + }) + }) + connected = true + sync() + } + const observer = new MutationObserver(() => { + if (!connected) connect() + else sync() + }) + observer.observe(document.body, { subtree: true, childList: true }) + queueMicrotask(connect) + onCleanup(() => observer.disconnect()) + }) return ( - - - + ) } -export interface ToastV2RootComponentProps extends ToastRootProps { - class?: string - classList?: ComponentProps<"li">["classList"] +const ToastV2Context = createContext() + +export interface ToastV2RootComponentProps { + toastId: number children?: JSX.Element } function ToastV2Root(props: ToastV2RootComponentProps) { - return ( - - ) + return {props.children} } function ToastV2Icon(props: ComponentProps<"div">) { @@ -45,26 +83,43 @@ function ToastV2Content(props: ComponentProps<"div">) { return
} -function ToastV2Title(props: ToastTitleProps & ComponentProps<"div">) { - return +function ToastV2Title(props: ComponentProps<"div">) { + return
} -function ToastV2Description(props: ToastDescriptionProps & ComponentProps<"div">) { - return +function ToastV2Description(props: ComponentProps<"div">) { + return
} function ToastV2Actions(props: ComponentProps<"div">) { return
} -function ToastV2CloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) { +function ToastV2CloseButton(props: ComponentProps<"button">) { + const toastId = useContext(ToastV2Context) + const [local, rest] = splitProps(props, ["children", "onClick"]) return ( - - - + + ) +} + +function CloseIcon() { + return ( + ) } @@ -78,7 +133,29 @@ export const ToastV2 = Object.assign(ToastV2Root, { CloseButton: ToastV2CloseButton, }) -export { toaster as toasterV2 } +let toastV2Id = 0 + +export const toasterV2 = { + show(render: (props: { toastId: number }) => JSX.Element, options?: { duration?: number; persistent?: boolean }) { + const toastId = --toastV2Id + toast.custom((id) => render({ toastId: Number(id) }), { + id: toastId, + className: "toast-v2", + duration: options?.persistent ? Number.POSITIVE_INFINITY : options?.duration, + unstyled: true, + }) + return toastId + }, + dismiss(toastId?: number) { + if (toastId === undefined) { + activeToastV2ByKey.clear() + activeToastV2ById.clear() + } else { + releaseToastV2(activeToastV2ById.get(toastId)) + } + return toast.dismiss(toastId) + }, +} export interface ToastV2Action { label: string @@ -90,53 +167,107 @@ export interface ToastV2Options { title?: string description?: string icon?: JSX.Element + variant?: "default" | "success" | "error" | "loading" duration?: number persistent?: boolean actions?: ToastV2Action[] } +interface ActiveToastV2 { + id: number + key: string + options: ToastV2Options + actions?: JSX.Element +} + +const activeToastV2ByKey = new Map() +const activeToastV2ById = new Map() + export function showToastV2(options: ToastV2Options | string) { - const opts = typeof options === "string" ? { description: options } : options - return toaster.show((props) => { - const resolvedIcon = children(() => opts.icon) - return ( - -
- - {resolvedIcon()} - - - - {opts.title} - - - {opts.description} - - - -
- - - {opts.actions!.map((action) => ( - { - if (typeof action.onClick === "function") { - action.onClick() - } - toaster.dismiss(props.toastId) - }} - > - {action.label} - - ))} - - -
- ) + const opts: ToastV2Options = typeof options === "string" ? { description: options } : options + const key = JSON.stringify({ + title: opts.title, + description: opts.description, + variant: opts.variant, + duration: opts.duration, + persistent: opts.persistent, + actions: opts.actions?.map((action) => [action.label, action.variant]), }) + const active = activeToastV2ByKey.get(key) + const toasts = toast.getToasts() + + if (active && toasts.at(-1)?.id === active.id) { + active.options = opts + publishToastV2(active) + pulseToastV2(active.id) + return active.id + } + + if (active && toasts.some((item) => item.id === active.id)) toasterV2.dismiss(active.id) + releaseToastV2(active) + + const entry: ActiveToastV2 = { id: --toastV2Id, key, options: opts } + entry.actions = createToastV2Actions(entry) + activeToastV2ByKey.set(key, entry) + activeToastV2ById.set(entry.id, entry) + publishToastV2(entry) + return entry.id +} + +function publishToastV2(entry: ActiveToastV2) { + const release = () => releaseToastV2(entry) + toast(entry.options.title ?? "", { + id: entry.id, + description: entry.options.description, + icon: entry.options.icon, + action: entry.actions, + closeButton: true, + duration: entry.options.persistent ? Number.POSITIVE_INFINITY : entry.options.duration, + className: `toast-v2 toast-v2--${entry.options.variant ?? "default"}`, + testId: `toast-v2-${entry.id}`, + unstyled: true, + onDismiss: release, + onAutoClose: release, + }) +} + +function createToastV2Actions(entry: ActiveToastV2) { + if (!entry.options.actions?.length) return undefined + return ( + + {entry.options.actions.map((action, index) => ( + + ))} + + ) +} + +function pulseToastV2(toastId: number) { + if (typeof document === "undefined" || typeof requestAnimationFrame === "undefined") return + requestAnimationFrame(() => { + const element = document.querySelector(`[data-testid="toast-v2-${toastId}"]`) + if (!element || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return + element.animate([{ scale: 1 }, { scale: 1.025 }, { scale: 1 }], { duration: 160, easing: "ease-out" }) + }) +} + +function releaseToastV2(entry: ActiveToastV2 | undefined) { + if (!entry) return + if (activeToastV2ByKey.get(entry.key) === entry) activeToastV2ByKey.delete(entry.key) + if (activeToastV2ById.get(entry.id) === entry) activeToastV2ById.delete(entry.id) } export interface ToastV2PromiseOptions {