|
|
@@ -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> {
|