Files
kjol/go/webui/toast.go

172 lines
5.6 KiB
Go

package webui
import "kjol/vdom"
// Port of web/kit/Toast.tsx.
//
// NOTE: Solid's context API (useToast + addToast/success/error/warning/info/
// generic) is dropped — there is no context or portal in the neutral runtime.
// Callers own the []Toast list and its removal instead: build the toasts, pass
// them to ToastProvider, and handle OnDismiss to drop one by ID. The
// auto-generated toast IDs (generateId) become the caller's responsibility.
// NOTE: auto-dismiss timers, the requestAnimationFrame progress countdown, and
// the exit (fade/slide) animation are dropped (no timers/rAF here). The progress
// bar, when shown, renders full-width and static; the dismiss button removes the
// toast immediately.
// NOTE: the "circle-xmark" and "circle-info" icons are not in the default icon
// registry and render as empty boxes until an app registers them.
// ToastType selects a toast's accent color and leading icon.
type ToastType string
const (
ToastSuccess ToastType = "success"
ToastError ToastType = "error"
ToastWarning ToastType = "warning"
ToastInfo ToastType = "info"
ToastGeneric ToastType = "generic"
)
// ToastPosition is where the container is anchored on screen.
type ToastPosition string
const (
ToastTopRight ToastPosition = "top-right"
ToastTopLeft ToastPosition = "top-left"
ToastBottomRight ToastPosition = "bottom-right"
ToastBottomLeft ToastPosition = "bottom-left"
ToastTopCenter ToastPosition = "top-center"
ToastBottomCenter ToastPosition = "bottom-center"
)
// Toast is one notification. Duration is in ms (0 = no auto-dismiss and no
// progress bar). Dismissible shows the close button; ShowProgress shows the
// (static) progress bar when Duration > 0. Type defaults to ToastInfo.
type Toast struct {
ID string
Message string
Type ToastType
Duration int
Dismissible bool
ShowProgress bool
}
var toastTypeIcons = map[ToastType]string{
ToastSuccess: "circle-check",
ToastError: "circle-xmark",
ToastWarning: "triangle-exclamation",
ToastInfo: "circle-info",
ToastGeneric: "",
}
const toastContainerBase = "fixed z-[200] flex flex-col gap-2"
var toastContainerPositions = map[ToastPosition]string{
ToastTopRight: "top-4 right-4",
ToastTopLeft: "top-4 left-4",
ToastBottomRight: "bottom-4 right-4 flex-col-reverse",
ToastBottomLeft: "bottom-4 left-4 flex-col-reverse",
ToastTopCenter: "top-4 left-1/2 -translate-x-1/2",
ToastBottomCenter: "bottom-4 left-1/2 -translate-x-1/2 flex-col-reverse",
}
const toastBase = "relative overflow-hidden rounded-default shadow-lg border border-neutral-200 border-l-4 bg-white min-w-72 max-w-md transition-[opacity,transform] duration-150 ease-out"
var toastTypeBorder = map[ToastType]string{
ToastSuccess: "border-l-green-700",
ToastError: "border-l-red-700",
ToastWarning: "border-l-yellow-500",
ToastInfo: "border-l-sky-800",
ToastGeneric: "border-l-neutral-400",
}
var toastIconColor = map[ToastType]string{
ToastSuccess: "text-green-600",
ToastError: "text-red-600",
ToastWarning: "text-yellow-600",
ToastInfo: "text-sky-700",
ToastGeneric: "",
}
// ToastItem renders a single toast. onDismiss receives the toast's ID when the
// close button is pressed.
func ToastItem(t Toast, onDismiss func(string)) *vdom.VNode {
typ := t.Type
if typ == "" {
typ = ToastInfo
}
icon := toastTypeIcons[typ]
showProgress := t.ShowProgress && t.Duration > 0
row := []vdom.Mod{vdom.Attr("class", "flex items-start gap-3 p-4")}
if icon != "" {
row = append(row, Icon(icon, 20, cx("shrink-0 mt-0.5", toastIconColor[typ])))
}
row = append(row, vdom.El("div", vdom.Attr("class", "flex-1 text-sm text-neutral-800"), vdom.Text(t.Message)))
if t.Dismissible {
dismiss := []vdom.Mod{
vdom.Attr("class", "shrink-0 cursor-pointer text-neutral-400 hover:text-neutral-600 bg-transparent border-0 p-0 transition-colors"),
vdom.Attr("aria-label", "Dismiss"),
}
if onDismiss != nil {
id := t.ID
dismiss = append(dismiss, vdom.On(vdom.EVENT_CLICK, func() { onDismiss(id) }))
}
dismiss = append(dismiss, Icon("xmark", 16, ""))
row = append(row, vdom.El("button", dismiss...))
}
mods := []vdom.Mod{
vdom.Attr("class", cx(toastBase, toastTypeBorder[typ])),
vdom.Attr("role", "alert"),
vdom.El("div", row...),
}
if showProgress {
mods = append(mods, vdom.El("div", vdom.Attr("class", "h-1 w-full bg-neutral-100"),
vdom.El("div", vdom.Attr("class", "h-full bg-neutral-300"), vdom.Attr("style", "width:100%")),
))
}
return vdom.El("div", mods...)
}
// ToastProviderProps configures ToastProvider. Position defaults to
// ToastBottomRight; MaxToasts defaults to 5 (only the most recent are kept).
type ToastProviderProps struct {
Position ToastPosition
MaxToasts int
Toasts []Toast
OnDismiss func(string)
}
// ToastProvider renders its children followed by the fixed toast container. It
// replaces the TSX ToastContext.Provider; the wrapper uses display:contents so
// it introduces no layout box of its own.
func ToastProvider(p ToastProviderProps, children ...*vdom.VNode) *vdom.VNode {
position := p.Position
if position == "" {
position = ToastBottomRight
}
max := p.MaxToasts
if max <= 0 {
max = 5
}
toasts := p.Toasts
if len(toasts) > max {
toasts = toasts[len(toasts)-max:]
}
container := []vdom.Mod{
vdom.Attr("class", cx(toastContainerBase, toastContainerPositions[position])),
vdom.Attr("aria-live", "polite"),
vdom.Attr("aria-label", "Notifications"),
}
for _, t := range toasts {
container = append(container, ToastItem(t, p.OnDismiss))
}
mods := []vdom.Mod{vdom.Attr("class", "contents")}
mods = kids(mods, children)
mods = append(mods, vdom.El("div", container...))
return vdom.El("div", mods...)
}