Add fonts, autotable, autotable examples
This commit is contained in:
@@ -1,20 +1,22 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
// Port of web/kit/Toast.tsx.
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// Port of web/uikit/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.
|
||||
// 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
|
||||
@@ -90,7 +92,11 @@ var toastIconColor = map[ToastType]string{
|
||||
|
||||
// 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 {
|
||||
//
|
||||
// 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
|
||||
@@ -102,7 +108,7 @@ func ToastItem(t Toast, onDismiss func(string)) *vdom.VNode {
|
||||
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)))
|
||||
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"),
|
||||
@@ -113,20 +119,31 @@ func ToastItem(t Toast, onDismiss func(string)) *vdom.VNode {
|
||||
dismiss = append(dismiss, vdom.On(vdom.EVENT_CLICK, func() { onDismiss(id) }))
|
||||
}
|
||||
dismiss = append(dismiss, Icon("xmark", 16, ""))
|
||||
row = append(row, vdom.El("button", dismiss...))
|
||||
row = append(row, vdom.Button(dismiss...))
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("class", cx(toastBase, toastTypeBorder[typ])),
|
||||
vdom.Attr("role", "alert"),
|
||||
vdom.El("div", row...),
|
||||
vdom.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%")),
|
||||
// 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.El("div", mods...)
|
||||
return vdom.Div(mods...)
|
||||
}
|
||||
|
||||
// ToastProviderProps configures ToastProvider. Position defaults to
|
||||
@@ -161,11 +178,203 @@ func ToastProvider(p ToastProviderProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
vdom.Attr("aria-label", "Notifications"),
|
||||
}
|
||||
for _, t := range toasts {
|
||||
container = append(container, ToastItem(t, p.OnDismiss))
|
||||
container = append(container, ToastItem(t, p.OnDismiss, nil))
|
||||
}
|
||||
|
||||
mods := []vdom.Mod{vdom.Attr("class", "contents")}
|
||||
mods = kids(mods, children)
|
||||
mods = append(mods, vdom.El("div", container...))
|
||||
return vdom.El("div", mods...)
|
||||
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...)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user