mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-03 19:13:35 +00:00
feat(app): migrate to solid-sonner (#39519)
Co-authored-by: Brendan Allan <git@brendonovich.dev>
This commit is contained in:
parent
73009f0b09
commit
a9eda2e99e
10 changed files with 447 additions and 250 deletions
4
bun.lock
4
bun.lock
|
|
@ -1002,6 +1002,7 @@
|
|||
"remend": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"solid-sonner": "catalog:",
|
||||
"strip-ansi": "7.1.2",
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -1146,6 +1147,7 @@
|
|||
"shiki": "4.2.0",
|
||||
"solid-js": "1.9.10",
|
||||
"solid-list": "0.3.0",
|
||||
"solid-sonner": "0.3.1",
|
||||
"sst": "4.13.1",
|
||||
"tailwindcss": "4.1.11",
|
||||
"typescript": "5.8.2",
|
||||
|
|
@ -5127,6 +5129,8 @@
|
|||
|
||||
"solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="],
|
||||
|
||||
"solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="],
|
||||
|
||||
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
|
||||
|
||||
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,9 @@
|
|||
"build": "vite build",
|
||||
"serve": "vite preview",
|
||||
"test": "bun run test:unit && bun run test:browser",
|
||||
"test:unit": "bun test --only-failures --preload ./happydom.ts ./src",
|
||||
"test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src",
|
||||
"test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser",
|
||||
"test:unit:watch": "bun test --watch --preload ./happydom.ts ./src",
|
||||
"test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:local": "playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
|
|
|
|||
|
|
@ -33,8 +33,7 @@ import { createStore, produce, reconcile } from "solid-js/store"
|
|||
import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd"
|
||||
import type { DragEvent } from "@thisbeyond/solid-dnd"
|
||||
import { useProviders } from "@/hooks/use-providers"
|
||||
import { toaster } from "@opencode-ai/ui/toast"
|
||||
import { setV2Toast, showToast, ToastRegion } from "@/utils/toast"
|
||||
import { dismissToast, setV2Toast, showToast, ToastRegion } from "@/utils/toast"
|
||||
import { useServerSDK } from "@/context/server-sdk"
|
||||
import { normalizeProjectInfo } from "@/context/global-sync/utils"
|
||||
import { clearWorkspaceTerminals } from "@/context/terminal"
|
||||
|
|
@ -384,7 +383,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||
const dismissSessionAlert = (sessionKey: string) => {
|
||||
const toastId = toastBySession.get(sessionKey)
|
||||
if (toastId === undefined) return
|
||||
toaster.dismiss(toastId)
|
||||
dismissToast(toastId)
|
||||
toastBySession.delete(sessionKey)
|
||||
alertedAtBySession.delete(sessionKey)
|
||||
}
|
||||
|
|
@ -1459,7 +1458,7 @@ export default function LegacyLayout(props: ParentProps) {
|
|||
title: language.t("workspace.resetting.title"),
|
||||
description: language.t("workspace.resetting.description"),
|
||||
})
|
||||
const dismiss = () => toaster.dismiss(progress)
|
||||
const dismiss = () => dismissToast(progress)
|
||||
|
||||
const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => [])
|
||||
|
||||
|
|
@ -2450,7 +2449,7 @@ function UpdateAvailableToast(props: {
|
|||
|
||||
onCleanup(() => {
|
||||
if (toastId === undefined) return
|
||||
toaster.dismiss(toastId)
|
||||
dismissToast(toastId)
|
||||
})
|
||||
|
||||
return null
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -104,6 +104,7 @@
|
|||
"remend": "catalog:",
|
||||
"shiki": "catalog:",
|
||||
"solid-list": "catalog:",
|
||||
"solid-sonner": "catalog:",
|
||||
"strip-ansi": "7.1.2"
|
||||
},
|
||||
"peerDependencies": {
|
||||
|
|
|
|||
|
|
@ -1,58 +1,182 @@
|
|||
[data-component="toast-v2-region"] {
|
||||
position: fixed;
|
||||
bottom: 48px;
|
||||
right: 32px;
|
||||
.toast-v2-region {
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-width: min(320px, calc(100vw - 64px));
|
||||
max-height: none;
|
||||
width: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
|
||||
[data-slot="toast-v2-list"] {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
max-height: none;
|
||||
overflow-y: visible;
|
||||
overflow-x: visible;
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
[data-component="toast-v2"] {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
.toast-v2 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 20px;
|
||||
column-gap: 12px;
|
||||
row-gap: 12px;
|
||||
width: 320px;
|
||||
max-width: 100%;
|
||||
padding: 12px;
|
||||
max-height: min(420px, calc(100dvh - 96px));
|
||||
overflow: hidden;
|
||||
pointer-events: auto;
|
||||
transition: transform 140ms ease-out;
|
||||
|
||||
box-sizing: border-box;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
border-radius: 8px;
|
||||
color: var(--v2-text-text-base);
|
||||
background: var(--v2-background-bg-layer-01);
|
||||
box-shadow: var(--v2-elevation-floating);
|
||||
transition:
|
||||
transform 280ms cubic-bezier(0.2, 0, 0, 1),
|
||||
opacity 160ms ease-out,
|
||||
height 280ms cubic-bezier(0.2, 0, 0, 1),
|
||||
box-shadow 160ms ease-out;
|
||||
|
||||
&[data-opened] {
|
||||
animation: toastV2PopIn 140ms ease-out;
|
||||
&:has(> [data-icon]) {
|
||||
grid-template-columns: 16px minmax(0, 1fr) 20px;
|
||||
}
|
||||
|
||||
&[data-closed] {
|
||||
animation: toastV2PopOut 100ms ease-in forwards;
|
||||
&:focus-visible {
|
||||
box-shadow:
|
||||
var(--v2-elevation-floating),
|
||||
0 0 0 2px var(--v2-border-border-focus);
|
||||
}
|
||||
|
||||
&[data-expanded="true"] {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
&[data-expanded="false"][data-front="false"] > * {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
&[data-expanded="false"][data-front="false"] {
|
||||
--y: translateY(calc(-1 * var(--toasts-before) * 9.5px)) scale(calc(1 - var(--toasts-before) * 0.05));
|
||||
}
|
||||
|
||||
> [data-icon] {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
align-self: start;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
> [data-content] {
|
||||
grid-column: 1;
|
||||
grid-row: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
&:has(> [data-icon]) > [data-content] {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
> [data-close-button] {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
position: static;
|
||||
align-self: start;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
transform: none;
|
||||
background: transparent;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
&:has(> [data-icon]) > [data-close-button] {
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
> [data-close-button]:hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
> [data-close-button]:active {
|
||||
background: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
> [data-close-button]:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
> [data-icon] > *,
|
||||
> [data-icon] svg,
|
||||
> [data-close-button] svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
[data-title],
|
||||
[data-slot="toast-v2-title"] {
|
||||
color: var(--v2-text-text-base);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.04px;
|
||||
font-synthesis: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-description],
|
||||
[data-slot="toast-v2-description"] {
|
||||
color: var(--v2-text-text-muted);
|
||||
text-wrap-style: pretty;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.04px;
|
||||
font-synthesis: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot="toast-v2-actions"] {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&:has(> [data-icon]) [data-slot="toast-v2-actions"] {
|
||||
grid-column: 2;
|
||||
}
|
||||
|
||||
[data-slot="toast-v2-actions"] [data-component="button-v2"] {
|
||||
min-height: 24px;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.04px;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
[data-slot="toast-v2-header"] {
|
||||
|
|
@ -69,34 +193,7 @@
|
|||
justify-content: center;
|
||||
width: 16px;
|
||||
height: 20px;
|
||||
min-width: 16px;
|
||||
min-height: 20px;
|
||||
color: var(--v2-icon-icon-base);
|
||||
|
||||
[data-component="icon"] {
|
||||
color: var(--v2-icon-icon-base);
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
> * {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="toast-v2-content"] {
|
||||
|
|
@ -104,112 +201,25 @@
|
|||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
}
|
||||
|
||||
[data-slot="toast-v2-title"] {
|
||||
color: var(--v2-text-text-base);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.04px;
|
||||
font-synthesis: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot="toast-v2-description"] {
|
||||
color: var(--v2-text-text-muted);
|
||||
text-wrap-style: pretty;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 440;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.04px;
|
||||
font-synthesis: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
[data-slot="toast-v2-actions"] {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
padding-left: 28px;
|
||||
}
|
||||
|
||||
[data-slot="toast-v2-actions"] [data-component="button-v2"] {
|
||||
min-height: 24px;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 530;
|
||||
line-height: 20px;
|
||||
letter-spacing: -0.04px;
|
||||
font-synthesis: none;
|
||||
}
|
||||
|
||||
[data-slot="toast-v2-close-button"] {
|
||||
flex-shrink: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
min-width: 20px;
|
||||
min-height: 20px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--v2-icon-icon-muted);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:hover {
|
||||
background: var(--v2-overlay-simple-overlay-hover);
|
||||
color: var(--v2-icon-icon-base);
|
||||
}
|
||||
|
||||
&:active {
|
||||
background: var(--v2-overlay-simple-overlay-pressed);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--v2-border-border-focus);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
display: block;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.toast-v2,
|
||||
.toast-v2 > * {
|
||||
animation: none !important;
|
||||
transition: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastV2PopIn {
|
||||
from {
|
||||
opacity: 0.8;
|
||||
transform: translateY(6px);
|
||||
@media (max-width: 600px) {
|
||||
.toast-v2-region {
|
||||
max-width: calc(100vw - 32px);
|
||||
}
|
||||
to {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes toastV2PopOut {
|
||||
from {
|
||||
transform: translateY(0);
|
||||
opacity: 1;
|
||||
}
|
||||
to {
|
||||
transform: translateY(6px);
|
||||
opacity: 0.8;
|
||||
.toast-v2 {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ Use brief titles/descriptions; limit actions to 1-2.
|
|||
- Toasts render in a portal and auto-dismiss unless persistent.
|
||||
|
||||
### Accessibility
|
||||
- TODO: confirm aria-live behavior from Kobalte Toast.
|
||||
- The region announces additions through an ARIA live region and expands with Alt+T.
|
||||
|
||||
### Theming/tokens
|
||||
- Uses \`data-component="toast-v2"\` and slot data attributes.
|
||||
|
|
@ -57,10 +57,10 @@ export const AllExamples = {
|
|||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M13.5554 10.4453V13.5564H11.7777H4.22211C3.23989 13.5564 2.44434 13.5564 2.44434 13.5564V10.4453"
|
||||
stroke="var(--icon-icon-base)"
|
||||
stroke="currentColor"
|
||||
/>
|
||||
<path d="M4.88867 6L7.99978 9.11111L11.1109 6" stroke="var(--icon-icon-base)" />
|
||||
<path d="M8 9.11198V2.44531" stroke="var(--icon-icon-base)" />
|
||||
<path d="M4.88867 6L7.99978 9.11111L11.1109 6" stroke="currentColor" />
|
||||
<path d="M8 9.11198V2.44531" stroke="currentColor" />
|
||||
</svg>
|
||||
),
|
||||
actions: [
|
||||
|
|
|
|||
|
|
@ -1,40 +1,78 @@
|
|||
import { Toast as Kobalte, toaster } from "@kobalte/core/toast"
|
||||
import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast"
|
||||
import { Toaster, toast, type ToasterProps } from "solid-sonner"
|
||||
import type { ComponentProps, JSX } from "solid-js"
|
||||
import { Show, children } from "solid-js"
|
||||
import { createContext, onCleanup, onMount, splitProps, useContext } from "solid-js"
|
||||
import { Portal } from "solid-js/web"
|
||||
import { ButtonV2 } from "./button-v2"
|
||||
import "./button-v2.css"
|
||||
import "./toast-v2.css"
|
||||
|
||||
export interface ToastV2RegionProps extends ComponentProps<typeof Kobalte.Region> {}
|
||||
export interface ToastV2RegionProps extends ToasterProps {}
|
||||
|
||||
function ToastV2Region(props: ToastV2RegionProps) {
|
||||
const [local, rest] = splitProps(props, ["class", "className", "style", "toastOptions", "swipeDirections"])
|
||||
onMount(() => {
|
||||
const sync = () => {
|
||||
document.querySelectorAll<HTMLElement>(".toast-v2-region .toast-v2").forEach((element) => {
|
||||
const hidden = element.dataset.visible === "false"
|
||||
element.inert = hidden
|
||||
element.tabIndex = hidden ? -1 : 0
|
||||
})
|
||||
}
|
||||
let connected = false
|
||||
const connect = () => {
|
||||
const regions = document.querySelectorAll(".toast-v2-region")
|
||||
if (!regions.length) return
|
||||
observer.disconnect()
|
||||
regions.forEach((region) => {
|
||||
observer.observe(region, {
|
||||
subtree: true,
|
||||
childList: true,
|
||||
attributes: true,
|
||||
attributeFilter: ["data-visible"],
|
||||
})
|
||||
})
|
||||
connected = true
|
||||
sync()
|
||||
}
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!connected) connect()
|
||||
else sync()
|
||||
})
|
||||
observer.observe(document.body, { subtree: true, childList: true })
|
||||
queueMicrotask(connect)
|
||||
onCleanup(() => observer.disconnect())
|
||||
})
|
||||
return (
|
||||
<Portal>
|
||||
<Kobalte.Region data-component="toast-v2-region" {...props}>
|
||||
<Kobalte.List data-slot="toast-v2-list" />
|
||||
</Kobalte.Region>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
offset={{ right: 32, bottom: 48 }}
|
||||
mobileOffset={16}
|
||||
gap={12}
|
||||
duration={5000}
|
||||
swipeDirections={["bottom"]}
|
||||
className={["toast-v2-region", local.className, local.class].filter(Boolean).join(" ")}
|
||||
style={{ "--width": "320px", ...local.style } as JSX.CSSProperties}
|
||||
toastOptions={{
|
||||
...local.toastOptions,
|
||||
unstyled: true,
|
||||
closeButton: true,
|
||||
closeButtonAriaLabel: local.toastOptions?.closeButtonAriaLabel ?? "Dismiss",
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
</Portal>
|
||||
)
|
||||
}
|
||||
|
||||
export interface ToastV2RootComponentProps extends ToastRootProps {
|
||||
class?: string
|
||||
classList?: ComponentProps<"li">["classList"]
|
||||
const ToastV2Context = createContext<number>()
|
||||
|
||||
export interface ToastV2RootComponentProps {
|
||||
toastId: number
|
||||
children?: JSX.Element
|
||||
}
|
||||
|
||||
function ToastV2Root(props: ToastV2RootComponentProps) {
|
||||
return (
|
||||
<Kobalte
|
||||
data-component="toast-v2"
|
||||
classList={{
|
||||
...props.classList,
|
||||
[props.class ?? ""]: !!props.class,
|
||||
}}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
return <ToastV2Context.Provider value={props.toastId}>{props.children}</ToastV2Context.Provider>
|
||||
}
|
||||
|
||||
function ToastV2Icon(props: ComponentProps<"div">) {
|
||||
|
|
@ -45,26 +83,43 @@ function ToastV2Content(props: ComponentProps<"div">) {
|
|||
return <div data-slot="toast-v2-content" {...props} />
|
||||
}
|
||||
|
||||
function ToastV2Title(props: ToastTitleProps & ComponentProps<"div">) {
|
||||
return <Kobalte.Title data-slot="toast-v2-title" {...props} />
|
||||
function ToastV2Title(props: ComponentProps<"div">) {
|
||||
return <div data-slot="toast-v2-title" {...props} />
|
||||
}
|
||||
|
||||
function ToastV2Description(props: ToastDescriptionProps & ComponentProps<"div">) {
|
||||
return <Kobalte.Description data-slot="toast-v2-description" {...props} />
|
||||
function ToastV2Description(props: ComponentProps<"div">) {
|
||||
return <div data-slot="toast-v2-description" {...props} />
|
||||
}
|
||||
|
||||
function ToastV2Actions(props: ComponentProps<"div">) {
|
||||
return <div data-slot="toast-v2-actions" {...props} />
|
||||
}
|
||||
|
||||
function ToastV2CloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) {
|
||||
function ToastV2CloseButton(props: ComponentProps<"button">) {
|
||||
const toastId = useContext(ToastV2Context)
|
||||
const [local, rest] = splitProps(props, ["children", "onClick"])
|
||||
return (
|
||||
<Kobalte.CloseButton data-slot="toast-v2-close-button" aria-label="Dismiss" {...props}>
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M4.25 11.75L11.75 4.25" stroke="currentColor" />
|
||||
<path d="M11.75 11.75L4.25 4.25" stroke="currentColor" />
|
||||
</svg>
|
||||
</Kobalte.CloseButton>
|
||||
<button
|
||||
type="button"
|
||||
data-slot="toast-v2-close-button"
|
||||
aria-label="Dismiss"
|
||||
{...rest}
|
||||
onClick={(event) => {
|
||||
if (typeof local.onClick === "function") local.onClick(event)
|
||||
if (!event.defaultPrevented && toastId !== undefined) toasterV2.dismiss(toastId)
|
||||
}}
|
||||
>
|
||||
{local.children ?? <CloseIcon />}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function CloseIcon() {
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
|
||||
<path d="M4.25 11.75L11.75 4.25" stroke="currentColor" />
|
||||
<path d="M11.75 11.75L4.25 4.25" stroke="currentColor" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -78,7 +133,29 @@ export const ToastV2 = Object.assign(ToastV2Root, {
|
|||
CloseButton: ToastV2CloseButton,
|
||||
})
|
||||
|
||||
export { toaster as toasterV2 }
|
||||
let toastV2Id = 0
|
||||
|
||||
export const toasterV2 = {
|
||||
show(render: (props: { toastId: number }) => JSX.Element, options?: { duration?: number; persistent?: boolean }) {
|
||||
const toastId = --toastV2Id
|
||||
toast.custom((id) => render({ toastId: Number(id) }), {
|
||||
id: toastId,
|
||||
className: "toast-v2",
|
||||
duration: options?.persistent ? Number.POSITIVE_INFINITY : options?.duration,
|
||||
unstyled: true,
|
||||
})
|
||||
return toastId
|
||||
},
|
||||
dismiss(toastId?: number) {
|
||||
if (toastId === undefined) {
|
||||
activeToastV2ByKey.clear()
|
||||
activeToastV2ById.clear()
|
||||
} else {
|
||||
releaseToastV2(activeToastV2ById.get(toastId))
|
||||
}
|
||||
return toast.dismiss(toastId)
|
||||
},
|
||||
}
|
||||
|
||||
export interface ToastV2Action {
|
||||
label: string
|
||||
|
|
@ -90,53 +167,107 @@ export interface ToastV2Options {
|
|||
title?: string
|
||||
description?: string
|
||||
icon?: JSX.Element
|
||||
variant?: "default" | "success" | "error" | "loading"
|
||||
duration?: number
|
||||
persistent?: boolean
|
||||
actions?: ToastV2Action[]
|
||||
}
|
||||
|
||||
interface ActiveToastV2 {
|
||||
id: number
|
||||
key: string
|
||||
options: ToastV2Options
|
||||
actions?: JSX.Element
|
||||
}
|
||||
|
||||
const activeToastV2ByKey = new Map<string, ActiveToastV2>()
|
||||
const activeToastV2ById = new Map<number, ActiveToastV2>()
|
||||
|
||||
export function showToastV2(options: ToastV2Options | string) {
|
||||
const opts = typeof options === "string" ? { description: options } : options
|
||||
return toaster.show((props) => {
|
||||
const resolvedIcon = children(() => opts.icon)
|
||||
return (
|
||||
<ToastV2 toastId={props.toastId} duration={opts.duration} persistent={opts.persistent}>
|
||||
<div data-slot="toast-v2-header">
|
||||
<Show when={resolvedIcon()}>
|
||||
<ToastV2.Icon>{resolvedIcon()}</ToastV2.Icon>
|
||||
</Show>
|
||||
<ToastV2.Content>
|
||||
<Show when={opts.title}>
|
||||
<ToastV2.Title>{opts.title}</ToastV2.Title>
|
||||
</Show>
|
||||
<Show when={opts.description}>
|
||||
<ToastV2.Description>{opts.description}</ToastV2.Description>
|
||||
</Show>
|
||||
</ToastV2.Content>
|
||||
<ToastV2.CloseButton />
|
||||
</div>
|
||||
<Show when={opts.actions?.length}>
|
||||
<ToastV2.Actions>
|
||||
{opts.actions!.map((action) => (
|
||||
<ButtonV2
|
||||
variant={action.variant === "secondary" ? "ghost" : "neutral"}
|
||||
size="small"
|
||||
data-action-variant={action.variant ?? "primary"}
|
||||
onClick={() => {
|
||||
if (typeof action.onClick === "function") {
|
||||
action.onClick()
|
||||
}
|
||||
toaster.dismiss(props.toastId)
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</ButtonV2>
|
||||
))}
|
||||
</ToastV2.Actions>
|
||||
</Show>
|
||||
</ToastV2>
|
||||
)
|
||||
const opts: ToastV2Options = typeof options === "string" ? { description: options } : options
|
||||
const key = JSON.stringify({
|
||||
title: opts.title,
|
||||
description: opts.description,
|
||||
variant: opts.variant,
|
||||
duration: opts.duration,
|
||||
persistent: opts.persistent,
|
||||
actions: opts.actions?.map((action) => [action.label, action.variant]),
|
||||
})
|
||||
const active = activeToastV2ByKey.get(key)
|
||||
const toasts = toast.getToasts()
|
||||
|
||||
if (active && toasts.at(-1)?.id === active.id) {
|
||||
active.options = opts
|
||||
publishToastV2(active)
|
||||
pulseToastV2(active.id)
|
||||
return active.id
|
||||
}
|
||||
|
||||
if (active && toasts.some((item) => item.id === active.id)) toasterV2.dismiss(active.id)
|
||||
releaseToastV2(active)
|
||||
|
||||
const entry: ActiveToastV2 = { id: --toastV2Id, key, options: opts }
|
||||
entry.actions = createToastV2Actions(entry)
|
||||
activeToastV2ByKey.set(key, entry)
|
||||
activeToastV2ById.set(entry.id, entry)
|
||||
publishToastV2(entry)
|
||||
return entry.id
|
||||
}
|
||||
|
||||
function publishToastV2(entry: ActiveToastV2) {
|
||||
const release = () => releaseToastV2(entry)
|
||||
toast(entry.options.title ?? "", {
|
||||
id: entry.id,
|
||||
description: entry.options.description,
|
||||
icon: entry.options.icon,
|
||||
action: entry.actions,
|
||||
closeButton: true,
|
||||
duration: entry.options.persistent ? Number.POSITIVE_INFINITY : entry.options.duration,
|
||||
className: `toast-v2 toast-v2--${entry.options.variant ?? "default"}`,
|
||||
testId: `toast-v2-${entry.id}`,
|
||||
unstyled: true,
|
||||
onDismiss: release,
|
||||
onAutoClose: release,
|
||||
})
|
||||
}
|
||||
|
||||
function createToastV2Actions(entry: ActiveToastV2) {
|
||||
if (!entry.options.actions?.length) return undefined
|
||||
return (
|
||||
<ToastV2.Actions>
|
||||
{entry.options.actions.map((action, index) => (
|
||||
<button
|
||||
type="button"
|
||||
data-component="button-v2"
|
||||
data-variant={action.variant === "secondary" ? "ghost" : "neutral"}
|
||||
data-size="small"
|
||||
data-action-variant={action.variant ?? "primary"}
|
||||
onClick={() => {
|
||||
const onClick = entry.options.actions?.[index]?.onClick
|
||||
if (typeof onClick === "function") onClick()
|
||||
toasterV2.dismiss(entry.id)
|
||||
}}
|
||||
>
|
||||
{action.label}
|
||||
</button>
|
||||
))}
|
||||
</ToastV2.Actions>
|
||||
)
|
||||
}
|
||||
|
||||
function pulseToastV2(toastId: number) {
|
||||
if (typeof document === "undefined" || typeof requestAnimationFrame === "undefined") return
|
||||
requestAnimationFrame(() => {
|
||||
const element = document.querySelector<HTMLElement>(`[data-testid="toast-v2-${toastId}"]`)
|
||||
if (!element || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return
|
||||
element.animate([{ scale: 1 }, { scale: 1.025 }, { scale: 1 }], { duration: 160, easing: "ease-out" })
|
||||
})
|
||||
}
|
||||
|
||||
function releaseToastV2(entry: ActiveToastV2 | undefined) {
|
||||
if (!entry) return
|
||||
if (activeToastV2ByKey.get(entry.key) === entry) activeToastV2ByKey.delete(entry.key)
|
||||
if (activeToastV2ById.get(entry.id) === entry) activeToastV2ById.delete(entry.id)
|
||||
}
|
||||
|
||||
export interface ToastV2PromiseOptions<T, U = unknown> {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue