381 lines
12 KiB
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-line border-l-4 bg-surface 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 dark:text-red-400",
|
|
ToastWarning: "text-yellow-600",
|
|
ToastInfo: "text-sky-700 dark:text-sky-400",
|
|
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-ink"), vdom.Text(t.Message)))
|
|
if t.Dismissible {
|
|
dismiss := []vdom.Mod{
|
|
vdom.Attr("class", "shrink-0 cursor-pointer text-ink-faint hover:text-ink-soft 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-surface-strong"),
|
|
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-surface-raised"),
|
|
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...)
|
|
}
|