Add fonts, autotable, autotable examples

This commit is contained in:
2026-07-13 13:01:26 -04:00
parent ea3d2a6d03
commit cf8342f8d4
71 changed files with 16084 additions and 1443 deletions

View File

@@ -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...)
}