Files
kjol/go/webui/toast.go

381 lines
12 KiB
Go

package webui
import (
"strconv"
"kjol/vdom"
"kjol/wasmruntime"
)
// Port of web/uikit/Toast.tsx.
//
// Two ways in, depending on who owns the queue:
//
// - Toaster (below) is the one you want. It owns the list, generates IDs, runs the
// auto-dismiss timers, and animates the countdown bar. It is the Go stand-in for
// the TSX's useToast context.
// - ToastProvider is the dumb half: it renders a list you hand it and calls you
// back on dismiss. Use it only if you already own the queue — and remember that
// NOTHING will remove a toast for you.
// 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.
//
// progressRef, when non-nil, is attached to the progress bar so a Toaster can drive
// it down to zero over the toast's lifetime (see Toaster.Push). Pass nil for a
// static bar.
func ToastItem(t Toast, onDismiss func(string), progressRef *vdom.Ref) *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.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.Button(dismiss...))
}
mods := []vdom.Mod{
vdom.Attr("class", cx(toastBase, toastTypeBorder[typ])),
vdom.Attr("role", "alert"),
vdom.Div(row...),
}
if showProgress {
// The bar is declared at full width; a Toaster transitions it to 0 over the
// toast's duration, imperatively (see Toaster.Push). Declaring the same style
// string on every render is what stops the reconciler's attribute diff from
// resetting it back to 100% mid-countdown.
bar := []vdom.Mod{
vdom.Attr("class", "h-full bg-neutral-300"),
vdom.Attr("style", "width:100%"),
}
if progressRef != nil {
bar = append(bar, vdom.WithRef(progressRef))
}
mods = append(mods, vdom.Div(vdom.Attr("class", "h-1 w-full bg-neutral-100"),
vdom.Div(bar...),
))
}
return vdom.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, nil))
}
mods := []vdom.Mod{vdom.Attr("class", "contents")}
mods = kids(mods, children)
mods = append(mods, vdom.Div(container...))
return vdom.Div(mods...)
}
// ---- Toaster: the managed version ----
// DefaultToastDuration is how long a toast lives when it does not say. 5s is long
// enough to read a sentence and short enough not to nag.
const DefaultToastDuration = 5000
// ToastSticky, as a Toast.Duration, means "never auto-dismiss": the user has to
// close it. It is negative because Go cannot distinguish an unset 0 from a
// deliberate one, and an unset duration must mean the sensible default, not
// "forever" — a toast that silently piles up is how a notification area rots.
const ToastSticky = -1
// Toaster owns a queue of toasts and DISMISSES THEM ON A TIMER. ToastProvider on
// its own does not: it renders whatever list it is handed, so a caller using it
// directly has to run the clocks itself (and, in practice, forgets — the toasts
// then stack up forever).
//
// Create it once, alongside your signals, and render it once near the root:
//
// toaster := webui.NewToaster(webui.ToasterOptions{Position: webui.ToastBottomRight})
// …
// toaster.Success("Saved.") // auto-dismisses after 5s
// toaster.Push(webui.Toast{Message: "Upload failed", Type: webui.ToastError,
// Duration: webui.ToastSticky}) // stays until dismissed
//
// return func() *vdom.VNode { return Div(page, toaster.Render()) }
type Toaster struct {
opts ToasterOptions
toasts *vdom.Signal[[]Toast]
// timers and bars are keyed by toast ID: the pending auto-dismiss, and the
// progress bar it is counting down.
timers map[string]int
bars map[string]*vdom.Ref
seq int
}
// ToasterOptions configures a Toaster.
type ToasterOptions struct {
Position ToastPosition
MaxToasts int
// DefaultDuration overrides DefaultToastDuration for toasts that do not set one.
DefaultDuration int
// NoProgress hides the countdown bar (which is otherwise shown on any toast that
// auto-dismisses).
NoProgress bool
}
// NewToaster creates the controller.
func NewToaster(o ToasterOptions) *Toaster {
if o.DefaultDuration == 0 {
o.DefaultDuration = DefaultToastDuration
}
return &Toaster{
opts: o,
toasts: vdom.NewSignal([]Toast{}),
timers: map[string]int{},
bars: map[string]*vdom.Ref{},
}
}
// Push adds a toast and schedules its dismissal. An empty ID gets one. Returns the
// ID, so a caller can dismiss it early.
//
// Duration 0 means the default; ToastSticky means never. The countdown is a real
// browser timer, so on the server (where SetTimeout is a no-op) nothing is
// scheduled — which is correct: SSR has no one to show a toast to.
func (t *Toaster) Push(toast Toast) string {
if toast.ID == "" {
t.seq++
toast.ID = "toast-" + strconv.Itoa(t.seq)
}
if toast.Type == "" {
toast.Type = ToastInfo
}
if toast.Duration == 0 {
toast.Duration = t.opts.DefaultDuration
}
toast.Dismissible = true
toast.ShowProgress = !t.opts.NoProgress && toast.Duration > 0
t.toasts.Set(append(t.toasts.Get(), toast))
if toast.Duration > 0 {
id := toast.ID
t.timers[id] = wasmruntime.SetTimeout(toast.Duration, func() {
delete(t.timers, id)
t.Dismiss(id)
})
t.startCountdown(id, toast.Duration)
}
return toast.ID
}
// startCountdown drives the progress bar from full to empty over the toast's life.
//
// It runs imperatively, not through a signal: a signal write per animation frame
// would re-render the whole application for a 1px-high bar. The bar is declared at
// width:100%, so it paints full first; then a transition takes it to 0.
func (t *Toaster) startCountdown(id string, duration int) {
if t.opts.NoProgress {
return
}
ref := t.bar(id)
wasmruntime.AfterRender(func() {
if !ref.Mounted() {
return
}
wasmruntime.SetStyle(ref, "transition", "width "+strconv.Itoa(duration)+"ms linear")
// One frame later: the browser has to commit width:100% before it can animate
// away from it. Setting both in the same frame just jumps to 0.
wasmruntime.RAF(func() { wasmruntime.SetStyle(ref, "width", "0%") })
})
}
func (t *Toaster) bar(id string) *vdom.Ref {
r, ok := t.bars[id]
if !ok {
r = vdom.NewRef()
t.bars[id] = r
}
return r
}
// Success / Error / Warning / Info push a toast of that type with the default
// duration — the common case.
func (t *Toaster) Success(msg string) string { return t.Push(Toast{Message: msg, Type: ToastSuccess}) }
func (t *Toaster) Error(msg string) string { return t.Push(Toast{Message: msg, Type: ToastError}) }
func (t *Toaster) Warning(msg string) string { return t.Push(Toast{Message: msg, Type: ToastWarning}) }
func (t *Toaster) Info(msg string) string { return t.Push(Toast{Message: msg, Type: ToastInfo}) }
// Dismiss removes a toast now, cancelling its pending auto-dismiss.
func (t *Toaster) Dismiss(id string) {
if timer, ok := t.timers[id]; ok {
wasmruntime.ClearTimeout(timer)
delete(t.timers, id)
}
delete(t.bars, id)
next := make([]Toast, 0, len(t.toasts.Get()))
for _, toast := range t.toasts.Get() {
if toast.ID != id {
next = append(next, toast)
}
}
t.toasts.Set(next)
}
// Clear removes every toast and cancels every pending timer.
func (t *Toaster) Clear() {
for _, timer := range t.timers {
wasmruntime.ClearTimeout(timer)
}
t.timers = map[string]int{}
t.bars = map[string]*vdom.Ref{}
t.toasts.Set(nil)
}
// Toasts is the current queue.
func (t *Toaster) Toasts() []Toast { return t.toasts.Get() }
// Render draws the toast container (and any children, unchanged — so it can wrap a
// subtree, as the TSX provider did).
func (t *Toaster) Render(children ...*vdom.VNode) *vdom.VNode {
position := t.opts.Position
if position == "" {
position = ToastBottomRight
}
limit := t.opts.MaxToasts
if limit <= 0 {
limit = 5
}
toasts := t.toasts.Get()
if len(toasts) > limit {
toasts = toasts[len(toasts)-limit:]
}
container := []vdom.Mod{
vdom.Attr("class", cx(toastContainerBase, toastContainerPositions[position])),
vdom.Attr("aria-live", "polite"),
vdom.Attr("aria-label", "Notifications"),
}
for _, toast := range toasts {
container = append(container, ToastItem(toast, t.Dismiss, t.bar(toast.ID)))
}
mods := []vdom.Mod{vdom.Attr("class", "contents")}
mods = kids(mods, children)
mods = append(mods, vdom.Div(container...))
return vdom.Div(mods...)
}