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

@@ -48,6 +48,26 @@ go -C go vet ./...
go -C go test ./...
```
**The wasm host API** (`wasmruntime/host.go` + `host_wasm.go` / `host_native.go`) is how neutral
component code reaches the browser: element measurement (`Measure`, `Viewport`), imperative style
writes (`SetStyle` — positioning must NOT go through signals, which re-render the whole tree),
the post-commit hook (`AfterRender`, the only point at which a just-rendered element can be
measured), document/window listeners, timers, localStorage, and file download. It is **dual-build**:
real under `js && wasm`, no-op stubs natively — which is what lets `webui` call it unconditionally
and still SSR. Refs come from `vdom.Ref` + `vdom.WithRef`; `vdom.Portal` mounts children at
`document.body` (needed to escape `overflow:hidden` / `transform` ancestors).
The reconciler is wasm-only and needs a DOM, so `go test ./...` cannot reach it. It has its own
harness — a minimal DOM under node:
```
GOOS=js GOARCH=wasm go -C go test -exec="node testdata/domexec.js" ./wasmruntime
```
`webui` components that measure the page (Tooltip, Popover, Menu, Modal, DatePicker, Tutorial,
AutoTable) are **controllers**: create them once alongside your signals, never inside a render
closure. Floating panels share one positioning engine (`webui/position.go`, pure math, unit-tested
natively) driven by the `Floating` controller (`webui/floating.go`).
### web/
- `kit/` — Solid.js `.tsx` component kit. Apps import components as `@ui/*`.

View File

@@ -1420,6 +1420,14 @@ func twCompile(input, baseDir string, candidates []string) (string, int, error)
if e != nil {
return "", 0, e
}
// The preflight is written against Tailwind's compile-time CSS functions —
// `font-family: --theme(--default-font-family, …)` and five more like it. They
// have to be resolved here, exactly as the theme's own declarations are above.
// Left in, `--theme(…)` reaches the browser verbatim, which cannot parse it and
// so DROPS THE WHOLE DECLARATION: html ends up with no font-family at all and
// falls back to the browser default, and no @theme override of --font-sans can
// ever take effect.
substituteFunctions(pfAst, ds)
out = append(out, atRule("@layer", "base", pfAst...))
}

View File

@@ -215,3 +215,61 @@ func TestExtractCandidatesRegexLiterals(t *testing.T) {
}
}
}
// The preflight is written against Tailwind's compile-time CSS functions, e.g.
// `font-family: --theme(--default-font-family, …)`. If those are not resolved, they
// reach the browser verbatim; the browser cannot parse `--theme(…)` and DROPS THE
// WHOLE DECLARATION. The symptom is subtle and global: every page silently loses its
// font-family and falls back to the browser default, and no @theme override of
// --font-sans can ever take effect. That shipped for a while, so pin it.
func TestPreflightResolvesThemeFunctions(t *testing.T) {
css, _, err := twCompile(`@import "tailwindcss";`, ".", nil)
if err != nil {
t.Fatal(err)
}
flat := strings.Join(strings.Fields(css), " ")
if strings.Contains(css, "--theme(") {
t.Error("the compiled CSS still contains an unresolved --theme(…); the browser will drop those declarations")
}
if !strings.Contains(flat, "font-family: var(--default-font-family)") {
t.Error("preflight did not resolve html's font-family to a var()")
}
}
// And the whole point of resolving it: an app's @theme override of --font-sans must
// actually reach the page.
func TestThemeFontOverrideReachesHTML(t *testing.T) {
css, _, err := twCompile(`
@import "tailwindcss";
@font-face {
font-family: "Lora";
src: url("/fonts/lora.woff2") format("woff2");
}
@theme {
--font-sans: "Lora", serif;
}
`, ".", nil)
if err != nil {
t.Fatal(err)
}
// The chain the browser walks: html -> --default-font-family -> --font-sans.
flat := strings.Join(strings.Fields(css), " ")
for _, want := range []string{
"font-family: var(--default-font-family)",
"--default-font-family: var(--font-sans)",
`--font-sans: "Lora", serif`,
} {
if !strings.Contains(flat, want) {
t.Errorf("missing %q — the font override does not reach the page", want)
}
}
// A vendored face must survive the build; dropping it would leave the family
// declared but never loaded.
if !strings.Contains(css, "@font-face") || !strings.Contains(css, "lora.woff2") {
t.Error("@font-face was dropped from the output")
}
}

View File

@@ -38,12 +38,17 @@ func KitPage(d Deps) func() *VNode {
acc := NewSignal(0)
notify := NewSignal(true)
span := NewSignal("week")
modalOpen := NewSignal(false)
menuOpen := NewSignal(false)
name := NewSignal("")
email := NewSignal("")
plan := NewSignal("pro")
// Floating components are CONTROLLERS: they own refs, timers and open state, so
// they are built once here — never inside the render closure below, which would
// rebuild them (and lose their state) on every frame.
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
tip := ui.NewHoverTooltip(ui.PlacementTop, "")
return func() *VNode {
return Div(Attr("class", "space-y-8"),
Div(
@@ -148,39 +153,45 @@ func KitPage(d Deps) func() *VNode {
),
),
kitSection("Overlays (interactive)",
kitSection("Overlays (measured, portaled)",
row("flex flex-wrap items-center gap-4",
// Modal, toggled by a signal.
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: func() { modalOpen.Set(true) }}),
// Menu, toggled by a signal.
ui.Menu("relative inline-block",
ui.MenuTrigger(func() { menuOpen.Set(!menuOpen.Get()) }, "",
ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Menu ▾"})),
ui.MenuContent(menuOpen.Get(), ui.MenuPlacementBottomStart, "",
ui.MenuItem(ui.MenuItemProps{Icon: "check", OnClick: func() { menuOpen.Set(false) }}, Text("Profile")),
ui.MenuItem(ui.MenuItemProps{OnClick: func() { menuOpen.Set(false) }}, Text("Settings")),
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
// The menu measures itself against the viewport: drag the window
// narrow, or scroll it to the bottom, and it flips/shifts to stay on
// screen. Items close the menu themselves — no callback plumbing.
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
caret := ""
if open {
caret = " ▴"
}
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Menu" + caret})
}),
menu.Content("",
menu.Item(ui.MenuItemProps{Icon: "check"}, Text("Profile")),
menu.Item(ui.MenuItemProps{}, Text("Settings")),
ui.MenuDivider(""),
ui.MenuItem(ui.MenuItemProps{OnClick: func() { menuOpen.Set(false) }}, Text("Sign out")),
menu.Item(ui.MenuItemProps{}, Text("Sign out")),
),
// The tooltip's arrow tracks the trigger even when the panel gets
// shifted away from it near a viewport edge.
tip.Render(Span(Text("A measured tooltip — try it near the window edge")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Hover me"})),
),
// Tooltip (pure CSS hover).
ui.HoverTooltip(Span(Text("A CSS-only tooltip")), "top", "",
ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Outline: true, Text: "Hover me"})),
),
ui.Modal(ui.ModalProps{
IsOpen: modalOpen.Get(),
OnClose: func() { modalOpen.Set(false) },
Size: ui.ModalMedium,
modal.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
},
P(Attr("class", "text-neutral-600"), Text("This modal is toggled by a signal; the backdrop and close button round-trip through OnClose.")),
P(Attr("class", "text-neutral-600"),
Text("Portaled to document.body, so it is not clipped by any ancestor. Escape closes "+
"the topmost modal; the backdrop click closes too.")),
),
),
ui.Alert(ui.AlertGray, "About this page",
Text("Floating/positioned components (menus, tooltips, modals, dropdowns) are ported as "+
"structure + Tailwind + signal/event wiring — the neutral runtime has no floating-ui, "+
"portals, or element measurement, so positioning is approximated with static classes.")),
ui.Alert(ui.AlertGreen, "About this page",
Text("Menus, tooltips, modals and dropdowns are now really measured: they are portaled to "+
"document.body, positioned from getBoundingClientRect against the viewport, and they "+
"flip and shift to stay on screen. Resize the window or scroll while one is open.")),
)
}
}

View File

@@ -0,0 +1,350 @@
package app
import (
"strconv"
. "kjol/vdom"
ui "kjol/webui"
)
// Every floating component in the kit, on one page: tooltips, popovers, menus and
// submenus, the date picker, modals (plain, confirm, wizard, imperative), toasts,
// and the tutorial's spotlight coachmarks.
//
// All of them are CONTROLLERS. They own refs, timers and open state, so they are
// created once here — never inside the render closure, which runs on every signal
// write and would rebuild them (and their refs) from scratch every frame. That is
// the single rule to remember about the floating layer.
//
// The page is NOT `static`: nothing is open during SSR anyway, so pre-rendering it
// buys nothing, and it keeps the example honest about which routes need it.
//
//gowasm:page /overlays layout=app
func OverlaysPage(d Deps) func() *VNode {
// --- tooltips -----------------------------------------------------------
tipTop := ui.NewHoverTooltip(ui.PlacementTop, "")
tipRight := ui.NewHoverTooltip(ui.PlacementRight, "")
tipFocus := ui.NewFocusTooltip(ui.PlacementBottom, "")
tipFast := ui.NewTooltip(ui.TooltipProps{Placement: ui.PlacementTop, Delay: -1})
// --- popovers -----------------------------------------------------------
pop := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomStart})
popEnd := ui.NewPopover(ui.PopoverProps{Placement: ui.PlacementBottomEnd})
hoverPop := ui.NewHoverPopover(ui.HoverPopoverProps{
Placement: ui.PlacementTop,
// The bridge: the cursor gets 300ms of grace to cross the gap from the
// trigger onto the panel. Without it, the panel closes in the dead space
// between them — which is exactly what happens once a panel is portaled and
// CSS :hover no longer reaches it.
HoverCloseDelay: 300,
})
// --- menus --------------------------------------------------------------
menu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart})
sub := ui.NewSubmenu(menu)
hoverMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomStart, OpenOnHover: true})
// --- date pickers -------------------------------------------------------
picked := NewSignal("")
dp := ui.NewDatePicker(ui.DatePickerProps{
Placeholder: "Pick a date",
Clearable: true,
OnChange: func(v string) { picked.Set(v) },
})
dob := ui.NewDateOfBirthPicker(ui.DatePickerProps{Placeholder: "Date of birth"})
// --- modals -------------------------------------------------------------
modal := ui.NewModal(ui.ModalOptions{Size: ui.ModalMedium})
nested := ui.NewModal(ui.ModalOptions{Size: ui.ModalSmall})
deleted := NewSignal(false)
confirm := ui.NewModal(ui.ModalOptions{})
// --- wizard -------------------------------------------------------------
wizardName := NewSignal("")
wizardDone := NewSignal(false)
wizard := ui.NewWizard(ui.ModalOptions{})
// --- toasts -------------------------------------------------------------
// The Toaster owns the queue AND the clocks: it generates IDs, runs the
// auto-dismiss timer, and animates the countdown bar down to zero. (ToastProvider,
// the dumb half, renders a list you hand it and removes nothing — a toast pushed
// through it stays until you take it away yourself.)
toaster := ui.NewToaster(ui.ToasterOptions{Position: ui.ToastBottomRight})
pushToast := func(kind ui.ToastType, msg string) {
toaster.Push(ui.Toast{Message: msg, Type: kind})
}
// --- tutorial -----------------------------------------------------------
// Steps target elements by CSS SELECTOR. The tour resolves each one with
// document.querySelector, measures it, scrolls it into view, and cuts a hole in
// the dimmed overlay around it — the spotlight animates from target to target.
tour := ui.NewTutorial(ui.TutorialOptions{
Steps: []ui.TutorialStep{
{
Title: "Tooltips",
Target: "#demo-tooltips",
Content: func() *VNode { return Text("Measured, portaled, and they flip near a viewport edge.") },
},
{
Title: "Popovers",
Target: "#demo-popovers",
Placement: ui.PlacementBottom,
Content: func() *VNode { return Text("Click or hover. The hover bridge lets you reach the panel.") },
},
{
Title: "Menus",
Target: "#demo-menus",
Content: func() *VNode { return Text("Items close the menu themselves; submenus are portaled.") },
},
{
// No Target: the page dims flat and the card centres in the viewport.
Title: "That's the tour",
Content: func() *VNode { return Text("Escape ends it. Arrow keys and Enter move between steps.") },
},
},
})
return func() *VNode {
return Div(Attr("class", "space-y-8"),
Div(
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("Overlays")),
P(Attr("class", "mt-1 text-neutral-500"),
Text("Every floating component, really measured: portaled to document.body, positioned from "+
"getBoundingClientRect against the viewport, flipping and shifting to stay on screen. "+
"Scroll or resize the window with one open.")),
row("mt-3 flex gap-2", tour.StartButton(0, "", Text("Take the tour"))),
),
// ---- tooltips ----
El("div", Attr("id", "demo-tooltips"),
kitSection("Tooltips",
row("flex flex-wrap items-center gap-3",
tipTop.Render(Span(Text("Above — the default")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Top"})),
tipRight.Render(Span(Text("To the right, unless it would run off the edge")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Right"})),
tipFast.Render(Span(Text("No open delay")),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Instant"})),
tipFocus.Render(Span(Text("Shown on focus, not hover — tab to the field")),
ui.FormInput(ui.FormInputProps{Placeholder: "Focus me"})),
),
P(Attr("class", "text-xs text-neutral-500"),
Text("Drag the window narrow and hover the Right one: it flips to the left, and its arrow "+
"follows. Near an edge the panel shifts back on screen and the arrow slides to keep "+
"pointing at the trigger — the original kit's arrow detached here.")),
),
),
// ---- popovers ----
El("div", Attr("id", "demo-popovers"),
kitSection("Popovers",
row("flex flex-wrap items-center gap-3",
pop.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Click me"})),
pop.Content(ui.PopoverContentProps{Class: "w-64"},
P(Attr("class", "text-sm text-neutral-600"),
Text("Click outside, or press Escape, to close. Only the topmost floating closes per press.")),
),
popEnd.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Aligned to my right edge"})),
popEnd.Content(ui.PopoverContentProps{Class: "w-56"},
P(Attr("class", "text-sm text-neutral-600"), Text("Placement bottom-end.")),
),
hoverPop.Trigger(ui.PopoverTriggerProps{},
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Hover me, then reach the panel"})),
hoverPop.Content(ui.PopoverContentProps{Class: "w-64"},
P(Attr("class", "text-sm text-neutral-600"),
Text("Move the cursor across the gap and onto this panel — it stays open. "+
"Select this text to prove it.")),
),
),
),
),
// ---- menus ----
El("div", Attr("id", "demo-menus"),
kitSection("Menus & submenus",
row("flex flex-wrap items-center gap-3",
menu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
caret := " ▾"
if open {
caret = " ▴"
}
return ui.Button(ui.ButtonProps{Color: ui.ButtonWhite, Text: "Actions" + caret})
}),
menu.Content("",
menu.Item(ui.MenuItemProps{Icon: "check", OnClick: func() { pushToast(ui.ToastSuccess, "Profile opened") }},
Text("Profile")),
menu.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastInfo, "Settings opened") }},
Text("Settings")),
// The submenu is portaled — it used to be clipped by the parent
// menu's own overflow-y-auto.
sub.Submenu(ui.SubmenuProps{Trigger: "More", Icon: "ellipsis"},
sub.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastInfo, "Archived") }}, Text("Archive")),
sub.Item(ui.MenuItemProps{OnClick: func() { pushToast(ui.ToastWarning, "Duplicated") }}, Text("Duplicate")),
),
ui.MenuDivider(""),
// KeepOpen is the TSX's closeOnClick inverted: by default an item
// closes the menu, which the first Go port dropped entirely.
menu.Item(ui.MenuItemProps{KeepOpen: true, OnClick: func() { pushToast(ui.ToastGeneric, "Menu stayed open") }},
Text("Stay open (KeepOpen)")),
menu.Item(ui.MenuItemProps{Icon: "arrow-right-from-bracket",
OnClick: func() { pushToast(ui.ToastError, "Signed out") }}, Text("Sign out")),
),
hoverMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(bool) *VNode {
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Opens on hover"})
}),
hoverMenu.Content("",
hoverMenu.Item(ui.MenuItemProps{}, Text("One")),
hoverMenu.Item(ui.MenuItemProps{}, Text("Two")),
),
),
P(Attr("class", "text-xs text-neutral-500"),
Text("Opening one menu closes the other: a single-open manager, with submenus exempt "+
"(Standalone), or a submenu would close its own parent.")),
),
),
// ---- date pickers ----
kitSection("Date picker",
row("grid gap-4 sm:grid-cols-2",
row("flex flex-col gap-1",
ui.FormLabel(ui.FormLabelProps{}, Text("Date (portaled, flips near the bottom)")),
dp.Render(),
),
row("flex flex-col gap-1",
ui.FormLabel(ui.FormLabelProps{}, Text("Date of birth (inline, three selects)")),
dob.Render(),
),
),
P(Attr("class", "text-xs text-neutral-500"),
Text("Picked: \""+picked.Get()+"\". Type into the field too — it parses loosely "+
"(7/4/26, Jul 4 2026, 2026-07-04) and commits on blur.")),
),
// ---- modals ----
kitSection("Modals",
row("flex flex-wrap items-center gap-3",
ui.Button(ui.ButtonProps{Color: ui.ButtonPrimary, Text: "Open modal", OnClick: modal.Open}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Outline: true, Text: "Delete something…", OnClick: confirm.Open}),
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open wizard", OnClick: wizard.Open}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Text: "Open imperatively",
OnClick: func() {
// No component in the tree owns this one: OpenModal hands content
// to the shared host rendered in the layout.
ui.OpenModal(func() *VNode {
return ui.ModalContent(ui.ModalContentProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Opened from anywhere")),
},
P(Attr("class", "text-neutral-600"),
Text("This content was not rendered by any component — it was handed to "+
"ModalHost (see AppLayout) by webui.OpenModal.")),
)
}, ui.ModalOptions{Size: ui.ModalSmall})
}}),
),
P(Attr("class", "text-xs text-neutral-500"),
Text("Deleted: "+strconv.FormatBool(deleted.Get())+
". Open the modal, then the nested one inside it, and press Escape twice — "+
"modals unwind one layer per press.")),
modal.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("A modal")),
Footer: ui.Button(ui.ButtonProps{Color: ui.ButtonNeutral, Text: "Close", OnClick: modal.Close}),
},
P(Attr("class", "text-neutral-600"),
Text("Portaled to document.body, so no ancestor's overflow:hidden can clip it. It fades "+
"and scales in — a double requestAnimationFrame, because a single frame does not "+
"give the browser time to commit the initial style.")),
row("mt-4",
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Outline: true, Text: "Open a nested modal", OnClick: nested.Open}),
),
),
nested.Render(ui.ModalProps{
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Nested")),
},
P(Attr("class", "text-neutral-600"), Text("Escape closes THIS one first, not the one behind it.")),
),
confirm.Confirm(ui.ConfirmModalProps{
Title: "Delete row",
Message: "This cannot be undone.",
OnConfirm: func() {
deleted.Set(true)
pushToast(ui.ToastError, "Row deleted")
},
}),
wizard.Render(ui.WizardProps{
Title: "Set up your account",
FinishText: "Finish",
OnComplete: func() {
wizardDone.Set(true)
pushToast(ui.ToastSuccess, "Wizard complete: "+wizardName.Get())
},
Steps: []ui.WizardStep{
{
Title: "Your name",
// Each step gets its own context: SetCanContinue gates THIS step's
// Next button, which a single shared bool could not express.
Content: func(ctx ui.WizardStepContext) *VNode {
ctx.SetCanContinue(wizardName.Get() != "")
return row("flex flex-col gap-1",
ui.FormLabel(ui.FormLabelProps{}, Text("Name (required to continue)")),
ui.FormInput(ui.FormInputProps{
Value: wizardName.Get(),
Placeholder: "Ada Lovelace",
OnInput: func(v string) { wizardName.Set(v) },
}),
)
},
},
{
Title: "Confirm",
Content: func(ctx ui.WizardStepContext) *VNode {
ctx.SetCanContinue(true)
return P(Attr("class", "text-neutral-600"),
Text("All set for "+wizardName.Get()+". Finish to close."))
},
},
},
}),
),
// ---- toasts ----
kitSection("Toasts",
row("flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonGreen, Small: true, Text: "Success",
OnClick: func() { toaster.Success("Saved.") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonRed, Small: true, Text: "Error",
OnClick: func() { toaster.Error("Something went wrong.") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonBlue, Small: true, Text: "Info",
OnClick: func() { toaster.Info("Just so you know.") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Sticky (no timer)",
OnClick: func() {
toaster.Push(ui.Toast{
Message: "This one waits for you to dismiss it.",
Type: ui.ToastWarning,
Duration: ui.ToastSticky,
})
}}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true, Text: "Clear all",
OnClick: toaster.Clear}),
),
P(Attr("class", "text-xs text-neutral-500"),
Text("These auto-dismiss after 5 seconds — watch the bar count down; it is a CSS transition "+
"driven straight at the DOM, not a re-render per frame. A sticky one (Duration: "+
"ToastSticky) never leaves on its own. The menu items above raise toasts too, which is "+
"how you can see that an item really does close its own menu.")),
),
// The toast container and the tutorial's overlay both render here; both are
// fixed-position, so where they sit in the tree does not matter.
toaster.Render(),
tour.Render(),
)
}
}

View File

@@ -72,21 +72,40 @@ func PublicLayout(d Deps, content *VNode) *VNode {
)
}
// wideRoutes get a roomier container. A table with a dozen columns, a drag handle
// and three calculated columns has no business being squeezed into a reading-width
// column; prose pages still are.
var wideRoutes = map[string]bool{"/table": true}
//gowasm:layout app
func AppLayout(d Deps, content *VNode) *VNode {
width := "max-w-5xl"
if wideRoutes[d.Path()] {
width = "max-w-[100rem]"
}
return Div(Attr("class", "min-h-screen"),
Nav(Attr("class", "app-nav border-b border-neutral-800 bg-neutral-900"),
Div(Attr("class", "mx-auto flex max-w-5xl items-center gap-2 px-4 py-3"),
// The nav tracks the content's width, so the logo stays flush with the page
// rather than floating in from the left on the wide routes.
Div(Attr("class", "mx-auto flex "+width+" items-center gap-2 px-4 py-3"),
A(Attr("class", "text-lg font-semibold tracking-tight text-text-on-dark no-underline"), Attr("href", "/chart"), navigate(d, "/chart"), Text("gowasm · app")),
Ul(Attr("class", "ml-4 flex items-center gap-1"),
navItem(d, "/chart", "Chart", true),
navItem(d, "/server", "Server", true),
navItem(d, "/data", "Data", true),
navItem(d, "/table", "Table", true),
navItem(d, "/overlays", "Overlays", true),
navItem(d, "/kit", "UI Kit", true)),
Ul(Attr("class", "ml-auto flex items-center"),
navItem(d, "/", "Home", true)),
)),
Main(Attr("class", "mx-auto max-w-5xl px-4 py-8"), content),
Main(Attr("class", "mx-auto "+width+" px-4 py-8"), content),
// The host for webui.OpenModal — content opened imperatively, by code that
// owns no component in the tree, is portaled out of here. Render it ONCE,
// near the root. It is an empty portal when nothing is open.
ui.ModalHost(),
)
}

View File

@@ -11,7 +11,9 @@ func Routes(d Deps) map[string]func() *vdom.VNode {
"/chart": ChartPage(d),
"/data": DataPage(d),
"/kit": KitPage(d),
"/overlays": OverlaysPage(d),
"/server": ServerPage(d),
"/table": TablePage(d),
}
}
@@ -21,6 +23,7 @@ var StaticPaths = map[string]bool{
"/about": true,
"/chart": true,
"/data": true,
"/table": true,
}
// RouteLayout maps each route to the name of the layout that wraps it.
@@ -30,7 +33,9 @@ var RouteLayout = map[string]string{
"/chart": "app",
"/data": "app",
"/kit": "app",
"/overlays": "app",
"/server": "app",
"/table": "app",
}
// LayoutFor wraps a page's content in the layout declared for its route.

View File

@@ -0,0 +1,171 @@
package app
import (
"bytes"
"os"
"strings"
"testing"
"kjol/vdom"
"kjol/webui"
)
func TestSSRPages(t *testing.T) {
for _, path := range []string{"/", "/about", "/chart", "/data", "/table", "/overlays", "/kit"} {
deps := Deps{Path: func() string { return path }}
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
t.Logf("%-8s %6d bytes portals=%d", path, len(html), strings.Count(html, "data-portal"))
if len(html) < 200 {
t.Errorf("%s rendered only %d bytes", path, len(html))
}
}
}
func TestSSRTablePage(t *testing.T) {
deps := Deps{Path: func() string { return "/table" }}
html := vdom.RenderHTML(Shell(deps, Routes(deps)))
// The table persists a personal layout in localStorage, which the SERVER CANNOT
// READ. So the server renders a SKELETON, not the default table: if it rendered
// the default one, a user who had reordered their columns would watch them
// rearrange themselves once the wasm booted.
//
// This is a real cost — the page ships no table content — and it is the price of
// never showing the wrong table. See webui.RestoreLayout.
if !strings.Contains(html, `aria-busy="true"`) {
t.Error("SSR /table should render the loading skeleton, not a table")
}
if !strings.Contains(html, "animate-pulse") {
t.Error("the skeleton bars are missing")
}
if strings.Contains(html, "Ada Lovelace") {
t.Error("SSR rendered table CONTENT — a user with a saved layout would watch it rearrange")
}
}
// renderedTable drives the very table the page renders, past its skeleton. Natively
// there is nothing to restore, so RestoreLayout just marks the layout settled.
func renderedTable(t *testing.T) string {
t.Helper()
highlight := vdom.NewSignal("")
table := newEmployeeTable(highlight)
table.SetRows(employees())
table.RestoreLayout()
return vdom.RenderHTML(table.Render())
}
// Once the layout has settled, the table renders in full.
func TestTableRendersOnceSettled(t *testing.T) {
html := renderedTable(t)
for _, want := range []string{"Ada Lovelace", "Salary"} {
if !strings.Contains(html, want) {
t.Errorf("settled table missing %q", want)
}
}
// PerPage is 5, so page one holds 5 of the 12 rows.
if got := strings.Count(html, "@example.com"); got != 5 {
t.Errorf("rendered %d rows, want 5 (one page)", got)
}
// The Rank column is HiddenByDefault.
if strings.Contains(html, ">Rank<") {
t.Error("a HiddenByDefault column was rendered")
}
if !strings.Contains(html, "Page 1 of 3") {
t.Error("pagination did not compute 3 pages for 12 rows at 5/page")
}
}
// Calculated columns, end to end through the page, in all three shapes.
//
// Page 1 (declared order):
//
// salary 1200.50 1500.00 980.00 1340.00 1610.25
// bonus 150.00 300.00 0.00 220.00 400.00
func TestSSRCalculatedColumns(t *testing.T) {
html := renderedTable(t)
// BASIC: sum over the operand columns [Salary, Bonus], combined ACROSS each row.
// If this ever aggregated DOWN the column instead, every row would read the same
// number — which is exactly the bug these values are here to catch.
for _, want := range []string{"$1,350.50", "$1,800.00", "$980.00", "$1,560.00", "$2,010.25"} {
if !strings.Contains(html, want) {
t.Errorf("Total comp missing %s (a per-row Salary + Bonus)", want)
}
}
// ADVANCED: ([Salary] + [Bonus]) * 12.
for _, want := range []string{"$16,206.00", "$21,600.00", "$11,760.00"} {
if !strings.Contains(html, want) {
t.Errorf("Annual column missing %s", want)
}
}
// ADVANCED, position-dependent: SUM({Salary:1:ROW()}) accumulates down the rows.
for _, want := range []string{"$2,700.50", "$3,680.50", "$5,020.50", "$6,630.75"} {
if !strings.Contains(html, want) {
t.Errorf("running total missing %s", want)
}
}
// SUMMARY: aggregated DOWN the column, over ALL 12 filtered rows — not the 5 on
// this page. 1200.50+1500+980+1340+1610.25+1120+1275.75+1050+1400+860+1180+990.
if !strings.Contains(html, "$14,506.50") {
t.Error("footer did not total the whole filtered set ($14,506.50)")
}
if !strings.Contains(html, "Average salary") {
t.Error("summary row label missing")
}
}
// The export path, driven through the very table the /table page renders.
//
// Export must write what the FILTER selected — every matching row across every page
// — not the five rows on screen; the columns the user can SEE, in their order; and
// the calculated columns, with each row's own value.
func TestTableExport(t *testing.T) {
highlight := vdom.NewSignal("")
table := newEmployeeTable(highlight)
table.RestoreLayout() // nothing to restore natively; reveals the table over its skeleton
table.SetRows(employees())
// Filter to one team, then render (which resolves FilteredRows).
table.SetSearchValue("Team", "Research", true)
table.Render()
csv := string(webui.ExportCSV(table.ExportColumns(), table.FilteredRows(), nil))
// PerPage is 5 and Research has 4 members, but the point is that export ignores
// paging entirely: every filtered row, no one else's.
for _, want := range []string{"Alan Turing", "Katherine Johnson", "Barbara Liskov", "Evelyn Boyd Granville"} {
if !strings.Contains(csv, want) {
t.Errorf("CSV missing filtered row %q", want)
}
}
if strings.Contains(csv, "Ada Lovelace") {
t.Error("CSV contains a row the filter excluded")
}
// Rank is HiddenByDefault, so it must not be exported.
if strings.Contains(csv, "Item 10") {
t.Error("CSV exported a hidden column")
}
// The calculated columns come along, and the running total ACCUMULATES —
// $1,500.00 then $2,840.00 (Turing + Johnson), not the same number twice.
if !strings.Contains(csv, "Running total") || !strings.Contains(csv, "$2,840.00") {
t.Errorf("running total did not accumulate in the export:\n%s", csv)
}
// And the PDF: a real file, with the same filtered content.
pdf := table.ExportPDFBytes(webui.AutoTablePDFHeader{
Title: "Employees", ShowDate: true, Orientation: webui.PDF_ORIENTATION_LANDSCAPE,
})
if !bytes.HasPrefix(pdf, []byte("%PDF-")) || !bytes.Contains(pdf, []byte("%%EOF")) {
t.Fatalf("PDF is not a PDF (%d bytes)", len(pdf))
}
if out := os.Getenv("PDF_OUT"); out != "" {
if err := os.WriteFile(out, pdf, 0o644); err != nil {
t.Fatal(err)
}
t.Logf("wrote %s (%d bytes)", out, len(pdf))
}
}

View File

@@ -0,0 +1,290 @@
package app
import (
. "kjol/vdom"
ui "kjol/webui"
)
// Employee is a row in the table demo. Salary and Bonus are both money, so a
// calculated column has two numeric columns to combine ACROSS a row.
type Employee struct {
Name string
Email string
Team string
Status string
Salary string
Bonus string
Rank string
Note string
}
func employees() []any {
rows := []Employee{
{"Ada Lovelace", "ada@example.com", "Engineering", "active", "$1,200.50", "$150.00", "Item 2", "Wrote the first algorithm."},
{"Alan Turing", "alan@example.com", "Research", "active", "$1,500.00", "$300.00", "Item 10", "Decidability, and the machine."},
{"Grace Hopper", "grace@example.com", "Engineering", "inactive", "$980.00", "$0.00", "Item 1", "Found the first bug. Literally."},
{"Katherine Johnson", "katherine@example.com", "Research", "active", "$1,340.00", "$220.00", "Item 3", "Orbital mechanics, by hand."},
{"Margaret Hamilton", "margaret@example.com", "Engineering", "active", "$1,610.25", "$400.00", "Item 21", "Coined 'software engineering'."},
{"Barbara Liskov", "barbara@example.com", "Research", "inactive", "$1,120.00", "$90.00", "Item 7", "The substitution principle."},
{"Radia Perlman", "radia@example.com", "Networking", "active", "$1,275.75", "$180.00", "Item 12", "Spanning tree protocol."},
{"Karen Sparck Jones", "karen@example.com", "Research", "active", "$1,050.00", "$60.00", "Item 5", "Inverse document frequency."},
{"Frances Allen", "frances@example.com", "Engineering", "inactive", "$1,400.00", "$250.00", "Item 9", "Optimizing compilers."},
{"Jean Bartik", "jean@example.com", "Engineering", "active", "$860.00", "$40.00", "Item 4", "Programmed the ENIAC."},
{"Evelyn Boyd Granville", "evelyn@example.com", "Research", "active", "$1,180.00", "$130.00", "Item 15", "Trajectory analysis."},
{"Annie Easley", "annie@example.com", "Networking", "inactive", "$990.00", "$75.00", "Item 6", "Rocket propulsion code."},
}
out := make([]any, len(rows))
for i, r := range rows {
out[i] = r
}
return out
}
func emp(row any) Employee { return row.(Employee) }
func tableColumns() []ui.AutoTableColumn {
return []ui.AutoTableColumn{
{
Key: "name", DisplayName: "Name", Sortable: true, SortIdentifier: "Name",
CSV: true, CSVValue: func(r any) string { return emp(r).Name },
// No Toggleable: the name is what identifies a row, so it cannot be hidden.
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-neutral-800", Text(emp(r).Name)) },
},
{
Key: "email", DisplayName: "Email", Sortable: true, SortIdentifier: "Email",
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Email },
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("text-neutral-500", Text(emp(r).Email)) },
},
{
Key: "team", DisplayName: "Team", Sortable: true, SortIdentifier: "Team",
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Team },
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("", Text(emp(r).Team)) },
},
{
Key: "status", DisplayName: "Status", Sortable: true, SortIdentifier: "Status",
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Status },
Cell: func(r any) *VNode {
color := ui.BadgeGreen
if emp(r).Status != "active" {
color = ui.BadgeNeutral
}
return ui.AutoTableTdLeft("", ui.Badge(ui.BadgeProps{Color: color}, Text(emp(r).Status)))
},
},
{
// SortTypeMoney parses "$1,200.50" as a number — a plain string sort would
// put $1,200.50 before $980.00.
Key: "salary", DisplayName: "Salary", DisplayPosition: ui.COL_POS_RIGHT,
Sortable: true, SortIdentifier: "Salary", SortType: ui.SortTypeMoney,
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Salary },
Cell: func(r any) *VNode { return ui.AutoTableTdRight("tabular-nums", Text(emp(r).Salary)) },
},
{
Key: "bonus", DisplayName: "Bonus", DisplayPosition: ui.COL_POS_RIGHT,
Sortable: true, SortIdentifier: "Bonus", SortType: ui.SortTypeMoney,
Toggleable: true, CSV: true, CSVValue: func(r any) string { return emp(r).Bonus },
Cell: func(r any) *VNode { return ui.AutoTableTdRight("tabular-nums", Text(emp(r).Bonus)) },
},
{
// SortTypeNumeric sorts "Item 2" before "Item 10".
Key: "rank", DisplayName: "Rank", Sortable: true, SortIdentifier: "Rank",
SortType: ui.SortTypeNumeric, Toggleable: true, HiddenByDefault: true,
CSV: true, CSVValue: func(r any) string { return emp(r).Rank },
Cell: func(r any) *VNode { return ui.AutoTableTdLeft("", Text(emp(r).Rank)) },
},
}
}
// newEmployeeTable builds the table controller.
//
// It is factored out of TablePage so a test can drive the very same table the page
// renders — the export test checks the bytes this exact configuration produces,
// rather than a second copy of it that could drift.
//
// The controller owns the search, sort, page, expansion and column state. Build it
// ONCE, never inside a render closure: rebuilding it per frame would reset every
// filter on each keystroke.
func newEmployeeTable(highlight *Signal[string]) *ui.AutoTableState {
return ui.NewAutoTableState(tableColumns(), ui.AutoTableStateOptions{
PerPage: 5,
// The table PAGES ITSELF to wherever the highlighted row landed after
// filtering and sorting.
HighlightMatch: func(r any) bool {
return highlight.Get() != "" && emp(r).Email == highlight.Get()
},
// Calculated columns come in two shapes, and the difference is the thing to
// understand:
//
// BASIC — a function over OPERAND COLUMNS, combined ACROSS each row.
// Sum over [Salary, Bonus] is this row's salary + bonus. It does
// NOT total the column. Operands are column KEYS (SortIdentifier),
// and subtract/divide are binary and ORDERED.
//
// ADVANCED — an Excel-style formula, which names columns by DISPLAY name:
// [Salary] is this row's cell, {Salary} is the whole column, and
// {Salary:1:ROW()} is everything up to this row — a running total.
//
// Either way they are evaluated against the FILTERED, SORTED rows, so filtering
// re-runs them. (ToCalcNumber parses "$1,200.50" for you.)
Calculated: []ui.UserCalculatedColumn{
{
// Basic: two columns, added together, per row.
ID: "comp", DisplayName: "Total comp", Fn: ui.CALC_FN_SUM,
Operands: []string{"Salary", "Bonus"},
DataType: ui.CALC_TYPE_MONEY, DisplayPosition: ui.COL_POS_RIGHT,
},
{
// Advanced: a formula.
ID: "annual", DisplayName: "Annual", Fn: ui.CALC_FN_CUSTOM,
Formula: "([Salary] + [Bonus]) * 12", DataType: ui.CALC_TYPE_MONEY,
DisplayPosition: ui.COL_POS_RIGHT,
},
{
// Advanced, and position-dependent: a running total down the page.
ID: "running", DisplayName: "Running total", Fn: ui.CALC_FN_CUSTOM,
Formula: "SUM({Salary:1:ROW()})", DataType: ui.CALC_TYPE_MONEY,
DisplayPosition: ui.COL_POS_RIGHT,
},
},
// A summary row goes the OTHER way: one column, aggregated DOWN the whole
// filtered set — not just the page on screen. Basic mode does that with a
// function + one operand; this one uses a formula for the same thing.
SummaryRows: []ui.UserSummaryRow{
{ID: "total", Label: "Total salary", Fn: ui.CALC_FN_SUM,
Operands: []string{"Salary"}, DataType: ui.CALC_TYPE_MONEY},
{ID: "avg", Label: "Average salary", Fn: ui.CALC_FN_CUSTOM,
Formula: "AVERAGE({Salary})", DataType: ui.CALC_TYPE_MONEY},
},
Accordion: true,
RowKey: func(r any) string { return emp(r).Email },
AccordionContent: func(r any) *VNode {
return P(Attr("class", "px-4 py-2 text-sm text-neutral-600"), Text(emp(r).Note))
},
Columns: ui.AutoTableColumnOptions{
Toggleable: true,
Draggable: true,
Resizable: true,
StorageKey: "gowasm-example-employees",
},
})
}
//gowasm:page /table layout=app static
func TablePage(d Deps) func() *VNode {
// Which row to spotlight, if any.
highlight := NewSignal("")
table := newEmployeeTable(highlight)
table.SetRows(employees())
// The export menu, with a submenu for the PDF's page orientation. Both are
// controllers, both built once. A submenu is Standalone — opening it must not
// close the menu it lives in.
exportMenu := ui.NewMenu(ui.MenuOptions{Placement: ui.PlacementBottomEnd})
pdfSub := ui.NewSubmenu(exportMenu)
// What the PDF prints above the table.
//
// Note what is NOT here: the footer lines. The export takes the table's OWN
// summary rows — including any the user builds at runtime in the Calculated
// editor — and evaluates them against the same filtered rows it is printing. Only
// pass Summaries explicitly to print something that is not one of the table's own
// rows.
pdfHeader := func(landscape bool) ui.AutoTablePDFHeader {
orientation := ui.PDF_ORIENTATION_PORTRAIT
if landscape {
orientation = ui.PDF_ORIENTATION_LANDSCAPE
}
return ui.AutoTablePDFHeader{
Title: "Employees",
Subtitle: "Exported from the gowasm example",
ShowDate: true,
Orientation: orientation,
}
}
return func() *VNode {
return Div(Attr("class", "space-y-6"),
Div(
H2(Attr("class", "text-2xl font-semibold tracking-tight text-text-heading"), Text("AutoTable")),
P(Attr("class", "mt-1 text-neutral-500"),
Text("Filtering, sorting, pagination, expandable rows and column management — all in Go. "+
"Drag a header to reorder, drag its right edge to resize; both persist across reloads.")),
),
table.Render(
ui.AutoTableWithHover(),
ui.AutoTableWithAlternate(),
ui.AutoTableWithSurroundingBorder(),
ui.AutoTableWithPaginationShowAll(),
ui.AutoTableWithSearchFields(
// One box, several fields: a global search.
table.GlobalSearch("Search name or email…", "Name", "Email"),
// Exact-match dropdown.
table.SelectSearch("Status", []string{"active", "inactive"}, "Any status"),
// IN-set: matches any of the selected teams.
table.MultiSelectSearch("Team", "Any team", []string{"Engineering", "Research", "Networking"}),
),
ui.AutoTableWithToolbarActions(
table.ColumnPicker(),
// Build calculated columns and footer rows at runtime. Basic picks a
// function and the columns it combines across each row; Advanced writes
// a formula, with insert menus for columns, functions and constants.
// The formula is compiled and previewed against the real first row as
// you type, so a typo shows up immediately rather than as a column of
// dashes. What you build is persisted with the rest of the layout.
table.CalculatedColumnEditor(),
// Export writes what the FILTER selected — every matching row across
// every page — not the five rows on screen. And it writes the columns
// you can actually see, in the order you dragged them into.
exportMenu.TriggerFunc(ui.MenuTriggerProps{Tag: "div"}, func(open bool) *VNode {
return ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Icon: "download", Text: "Export"})
}),
exportMenu.Content("",
exportMenu.Item(ui.MenuItemProps{Icon: "file-csv",
OnClick: func() { table.DownloadCSV("employees") }}, Text("Download CSV")),
// A submenu — portaled, so it is not clipped by the menu's own
// overflow-y-auto, which is what broke it before.
pdfSub.Submenu(ui.SubmenuProps{Trigger: "Download PDF", Icon: "file-pdf"},
pdfSub.Item(ui.MenuItemProps{
OnClick: func() { table.DownloadPDF("employees", pdfHeader(false)) }}, Text("Portrait")),
pdfSub.Item(ui.MenuItemProps{
OnClick: func() { table.DownloadPDF("employees", pdfHeader(true)) }}, Text("Landscape")),
),
ui.MenuDivider(""),
exportMenu.Item(ui.MenuItemProps{Icon: "print",
OnClick: func() { table.PrintPDF(pdfHeader(true)) }}, Text("Print")),
),
),
),
// Highlight + auto-page-jump: Radia is on page 3 by default, and the table
// pages itself to wherever she actually is once filters and sorting move her.
row("flex flex-wrap items-center gap-2",
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Text: "Find Radia Perlman",
OnClick: func() { highlight.Set("radia@example.com") }}),
ui.Button(ui.ButtonProps{Color: ui.ButtonLightNeutral, Small: true,
Text: "Clear highlight",
OnClick: func() { highlight.Set("") }}),
),
ui.Alert(ui.AlertBlue, "What to try",
Text("Search (it matches name OR email); pick a status; select several teams. Sort by Salary — "+
"it parses the currency, so $980 sorts below $1,200.50. Unhide Rank and sort it: 'Item 2' "+
"comes before 'Item 10'. Click a row to expand it. Drag a header to reorder, drag its right "+
"edge to resize — both survive a reload. Filter the table, then export: you get every "+
"matching row, not just this page. 'Find Radia' jumps to whichever page she is on.")),
)
}
}

View File

@@ -1,5 +1,59 @@
@import "tailwindcss";
/* ---------------------------------------------------------------------------
Lora, vendored from Google Fonts — self-hosted, not linked.
---------------------------------------------------------------------------
The files live in wwwroot/fonts (served by the dev server straight out of
./wwwroot), so the app pulls no third-party request at runtime: no CDN to be
blocked, no extra DNS round trip, and no dependency on fonts.gstatic.com being
up. It is a VARIABLE font, so ONE file per style covers weights 400-700 —
hence "font-weight: 400 700" rather than a file per weight.
Only the latin and latin-ext subsets are vendored (~120 KB in total). The
unicode-range on each face is what lets the browser skip downloading latin-ext
entirely unless the page actually uses a character from it.
To refresh: fetch
https://fonts.googleapis.com/css2?family=Lora:ital,wght@0,400..700;1,400..700
and re-download the woff2 URLs it names.
--------------------------------------------------------------------------- */
@font-face {
font-family: "Lora";
font-style: italic;
font-weight: 400 700; /* a variable font: one file covers the whole range */
font-display: swap;
src: url("/fonts/lora-latin-ext-italic.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Lora";
font-style: italic;
font-weight: 400 700; /* a variable font: one file covers the whole range */
font-display: swap;
src: url("/fonts/lora-latin-italic.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
font-family: "Lora";
font-style: normal;
font-weight: 400 700; /* a variable font: one file covers the whole range */
font-display: swap;
src: url("/fonts/lora-latin-ext-normal.woff2") format("woff2");
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
font-family: "Lora";
font-style: normal;
font-weight: 400 700; /* a variable font: one file covers the whole range */
font-display: swap;
src: url("/fonts/lora-latin-normal.woff2") format("woff2");
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
/* App-side design tokens the webui kit references (Tailwind v4 @theme). Brand
values live with the app; the kit stays generic. */
@theme {
@@ -11,4 +65,11 @@
--color-text-heading: #111827;
--color-text-on-dark: #f9fafb;
--color-text-on-dark-muted: #9ca3af;
/* Lora is the body face. Declaring it as --font-sans makes it the default for
everything (Tailwind's preflight sets `font-family: var(--font-sans)` on
html), and also gives you `font-sans` as a utility. --font-serif points at it
too, so `font-serif` is not a different, unvendored face. */
--font-sans: "Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;
--font-serif: "Lora", ui-serif, Georgia, Cambria, "Times New Roman", serif;
}

View File

@@ -60,7 +60,7 @@ func sampleQuotes() []app.Quote {
}
// render SSRs a static route's #app inner HTML; ok=false ships an empty #app
// (client-rendered). It's the same neutral render the client runs, so the client
// (client-rendered). It is the same neutral render the client runs, so the client
// hydrates it.
func render(path string) (string, bool) {
if !app.StaticPaths[path] {

File diff suppressed because one or more lines are too long

View File

@@ -89,7 +89,19 @@ func renderIDs(root *vdom.VNode) (*SNode, map[int]map[string]func(vdom.Event)) {
return walk(root), handlers
}
// serverEvent is the vdom.Event a server component sees when the client replays a
// handler invocation. Only the target's value survives the round trip (it is all
// the client sends); everything else — key, pointer coordinates, the DOM target,
// the DataTransfer — is meaningless on the server and reads as its zero value.
type serverEvent struct{ value string }
func (e serverEvent) PreventDefault() {}
func (e serverEvent) StopPropagation() {}
func (e serverEvent) Value() string { return e.value }
func (e serverEvent) Checked() bool { return false }
func (e serverEvent) Key() string { return "" }
func (e serverEvent) ClientX() int { return 0 }
func (e serverEvent) ClientY() int { return 0 }
func (e serverEvent) Target() any { return nil }
func (e serverEvent) SetData(_, _ string) {}
func (e serverEvent) GetData(string) string { return "" }

View File

@@ -14,4 +14,43 @@ const (
EVENT_MOUSEDOWN = "mousedown"
EVENT_MOUSEUP = "mouseup"
EVENT_MOUSEMOVE = "mousemove"
EVENT_MOUSEENTER = "mouseenter"
EVENT_MOUSELEAVE = "mouseleave"
EVENT_CONTEXTMENU = "contextmenu"
EVENT_SCROLL = "scroll"
EVENT_RESIZE = "resize"
EVENT_WHEEL = "wheel"
// focusin/focusout BUBBLE; focus/blur do not. Anything that needs to know a
// subtree gained focus (a tooltip staying open while a child is focused) must
// use these.
EVENT_FOCUSIN = "focusin"
EVENT_FOCUSOUT = "focusout"
// HTML5 drag-and-drop. The handler's Event carries the DataTransfer via
// SetData / GetData.
EVENT_DRAGSTART = "dragstart"
EVENT_DRAGOVER = "dragover"
EVENT_DRAGENTER = "dragenter"
EVENT_DRAGLEAVE = "dragleave"
EVENT_DROP = "drop"
EVENT_DRAGEND = "dragend"
)
// KeyboardEvent.key values, for Event.Key().
const (
KEY_ESCAPE = "Escape"
KEY_ENTER = "Enter"
KEY_SPACE = " "
KEY_TAB = "Tab"
KEY_BACKSPACE = "Backspace"
KEY_DELETE = "Delete"
KEY_ARROW_UP = "ArrowUp"
KEY_ARROW_DOWN = "ArrowDown"
KEY_ARROW_LEFT = "ArrowLeft"
KEY_ARROW_RIGHT = "ArrowRight"
KEY_HOME = "Home"
KEY_END = "End"
KEY_PAGE_UP = "PageUp"
KEY_PAGE_DOWN = "PageDown"
)

55
go/vdom/ref.go Normal file
View File

@@ -0,0 +1,55 @@
package vdom
// Ref is a handle on the real DOM node a VNode rendered to. It is the bridge the
// neutral component code uses to ask the browser for things it cannot know on its
// own — how big an element is, where it sits in the viewport — without importing
// anything platform-specific: the value inside is opaque (`any`), filled in by the
// wasm reconciler and always nil on the server. Read it through the wasmruntime
// host API (wasmruntime.Rect, SetStyle, Focus, …), which no-ops when it is nil.
//
// A component creates one ref per element it needs to measure and keeps it across
// renders (in a closure, next to its signals — NOT rebuilt inside the render
// function, or it would be a fresh empty ref every frame):
//
// panel := vdom.NewRef()
// return func() *VNode {
// return Div(WithRef(panel), Attr("class", "…"), …)
// }
//
// and later, after the DOM exists (see wasmruntime.AfterRender):
//
// r := wasmruntime.Rect(panel) // zero Rect if unmounted or on the server
type Ref struct{ node any }
// NewRef returns an unattached Ref.
func NewRef() *Ref { return &Ref{} }
// Node is the platform DOM handle (a js.Value in the browser), or nil if the ref
// is not currently attached to a mounted element. Component code should not need
// this — it is for the host API and the reconciler.
func (r *Ref) Node() any {
if r == nil {
return nil
}
return r.node
}
// Mounted reports whether the ref currently points at a live DOM node.
func (r *Ref) Mounted() bool { return r != nil && r.node != nil }
type refMod struct{ ref *Ref }
func (m refMod) apply(n *VNode) { n.Ref = m.ref }
// WithRef attaches a Ref to this element, so the DOM node it renders to can be
// measured and manipulated. Ignored on the server.
func WithRef(r *Ref) Mod { return refMod{r} }
// SetRefNode points a Ref at a platform DOM handle (or nil to detach). It exists
// for the reconciler and the host API, which live in another package; component
// code has no reason to call it.
func SetRefNode(r *Ref, node any) {
if r != nil {
r.node = node
}
}

View File

@@ -1,30 +1,81 @@
package vdom
// HTML tag helpers over El, for dot-import.
// HTML tag helpers over El.
//
// The set covers every tag kjol/webui actually renders, which is the bar that
// matters: a partial set is worse than none, because it forces a half-and-half style
// where `Div(...)` sits next to `El("thead", ...)` and the reader has to know which
// tags happen to be covered. If you reach for a tag that is not here, add it rather
// than falling back to El.
//
// El itself stays exported for the genuinely dynamic case — a tag chosen at runtime,
// as a component's `Tag` prop does.
// --- structure ---
func Div(m ...Mod) *VNode { return El("div", m...) }
func Span(m ...Mod) *VNode { return El("span", m...) }
func P(m ...Mod) *VNode { return El("p", m...) }
func Section(m ...Mod) *VNode { return El("section", m...) }
func Article(m ...Mod) *VNode { return El("article", m...) }
func Aside(m ...Mod) *VNode { return El("aside", m...) }
func Nav(m ...Mod) *VNode { return El("nav", m...) }
func Header(m ...Mod) *VNode { return El("header", m...) }
func Main(m ...Mod) *VNode { return El("main", m...) }
func Footer(m ...Mod) *VNode { return El("footer", m...) }
func Hr(m ...Mod) *VNode { return El("hr", m...) }
func Br(m ...Mod) *VNode { return El("br", m...) }
// --- headings ---
func H1(m ...Mod) *VNode { return El("h1", m...) }
func H2(m ...Mod) *VNode { return El("h2", m...) }
func H3(m ...Mod) *VNode { return El("h3", m...) }
func Hr(m ...Mod) *VNode { return El("hr", m...) }
func H4(m ...Mod) *VNode { return El("h4", m...) }
func H5(m ...Mod) *VNode { return El("h5", m...) }
func H6(m ...Mod) *VNode { return El("h6", m...) }
// --- inline ---
func A(m ...Mod) *VNode { return El("a", m...) }
func Strong(m ...Mod) *VNode { return El("strong", m...) }
func B(m ...Mod) *VNode { return El("b", m...) }
func Em(m ...Mod) *VNode { return El("em", m...) }
func I(m ...Mod) *VNode { return El("i", m...) }
func Small(m ...Mod) *VNode { return El("small", m...) }
func Code(m ...Mod) *VNode { return El("code", m...) }
func Label(m ...Mod) *VNode { return El("label", m...) }
func Pre(m ...Mod) *VNode { return El("pre", m...) }
// --- lists ---
func Ul(m ...Mod) *VNode { return El("ul", m...) }
func Ol(m ...Mod) *VNode { return El("ol", m...) }
func Li(m ...Mod) *VNode { return El("li", m...) }
// --- forms ---
func Form(m ...Mod) *VNode { return El("form", m...) }
func Fieldset(m ...Mod) *VNode { return El("fieldset", m...) }
func Legend(m ...Mod) *VNode { return El("legend", m...) }
func Label(m ...Mod) *VNode { return El("label", m...) }
func Input(m ...Mod) *VNode { return El("input", m...) }
func Textarea(m ...Mod) *VNode { return El("textarea", m...) }
func Select(m ...Mod) *VNode { return El("select", m...) }
func Option(m ...Mod) *VNode { return El("option", m...) }
func Button(m ...Mod) *VNode { return El("button", m...) }
// --- tables ---
func Table(m ...Mod) *VNode { return El("table", m...) }
func Thead(m ...Mod) *VNode { return El("thead", m...) }
func Tbody(m ...Mod) *VNode { return El("tbody", m...) }
func Tfoot(m ...Mod) *VNode { return El("tfoot", m...) }
func Tr(m ...Mod) *VNode { return El("tr", m...) }
func Th(m ...Mod) *VNode { return El("th", m...) }
func Td(m ...Mod) *VNode { return El("td", m...) }
func Caption(m ...Mod) *VNode { return El("caption", m...) }
// --- media and misc ---
func Img(m ...Mod) *VNode { return El("img", m...) }
func Canvas(m ...Mod) *VNode { return El("canvas", m...) }
func Dialog(m ...Mod) *VNode { return El("dialog", m...) }
// Svg and Path are the two SVG tags the icon layer needs. The reconciler creates
// them in the SVG namespace (see wasmruntime): an <svg> built as ordinary HTML is an
// inert unknown element that renders nothing.
func Svg(m ...Mod) *VNode { return El("svg", m...) }
func Path(m ...Mod) *VNode { return El("path", m...) }

View File

@@ -11,14 +11,24 @@ import (
)
// Event is a DOM event passed to handlers. The client provides a concrete
// implementation; on the server events are never invoked.
// implementation; on the server events are never invoked, and every accessor
// returns its zero value.
type Event interface {
PreventDefault()
StopPropagation()
Value() string // target.value (for inputs)
Checked() bool // target.checked (for checkboxes / radios)
Key() string // KeyboardEvent.key — compare against the KEY_* constants
ClientX() int // pointer position, viewport coordinates
ClientY() int //
Target() any // the platform DOM handle of event.target; nil on the server
SetData(format, data string) // DataTransfer — no-ops off a drag event
GetData(format string) string
}
// VNode is a virtual DOM node. Tag == "" is a text node (content in Text). HTML,
// if set on an element, is raw innerHTML (children ignored).
// if set on an element, is raw innerHTML (children ignored). Tag == TagPortal
// renders its children into document.body instead of in place.
type VNode struct {
Tag string
Text string
@@ -28,6 +38,9 @@ type VNode struct {
Events map[string]func(Event)
Children []*VNode
// Ref, if set, receives the DOM node this VNode renders to (see WithRef).
Ref *Ref
// Runtime holds the wasm reconciler's per-node bookkeeping (DOM handle,
// listener wrappers). It's `any` so this package stays platform-neutral; it
// is nil on the server.
@@ -37,6 +50,13 @@ type VNode struct {
// Mod configures a VNode while it is built.
type Mod interface{ apply(*VNode) }
// TagPortal marks a node whose children are mounted into document.body rather
// than into its own parent. It is how a floating panel escapes an ancestor's
// `overflow: hidden` (menus scroll, modals clip) or `transform` (which would
// re-root `position: fixed` onto the modal instead of the viewport). In the tree
// it occupies a hidden, zero-size placeholder; the children live at body level.
const TagPortal = "#portal"
// El builds an element VNode.
func El(tag string, mods ...Mod) *VNode {
n := &VNode{Tag: tag, Attrs: map[string]string{}, Props: map[string]string{}, Events: map[string]func(Event){}}
@@ -46,6 +66,17 @@ func El(tag string, mods ...Mod) *VNode {
return n
}
// Portal renders its children into document.body. On the server it renders the
// placeholder only — floating content is closed during SSR, so there is nothing
// to emit, and the placeholder keeps the client's hydration walk aligned.
func Portal(children ...*VNode) *VNode {
n := El(TagPortal)
for _, c := range children {
c.apply(n)
}
return n
}
// Text builds a text VNode.
func Text(s string) *VNode { return &VNode{Text: s} }
@@ -106,6 +137,13 @@ func writeNode(b *strings.Builder, n *VNode) {
b.WriteString(html.EscapeString(n.Text))
return
}
if n.Tag == TagPortal {
// Placeholder only — the children belong to document.body, which SSR does
// not own. Emitting the same element the client creates keeps hydration's
// childNodes lined up.
b.WriteString(`<div data-portal="" style="display:none"></div>`)
return
}
b.WriteByte('<')
b.WriteString(n.Tag)
writeAttrs(b, n.Attrs)

92
go/wasmruntime/host.go Normal file
View File

@@ -0,0 +1,92 @@
package wasmruntime
import (
"strconv"
"strings"
)
// The host API: the channel through which the browser tells WebAssembly things Go
// cannot work out on its own — how big an element is, where the pointer is, how
// tall the viewport is — plus the imperative escape hatches (write a style, focus
// an input, download a file) that a pure re-render cannot express.
//
// Every function here exists in two builds: the real one (host_wasm.go, js+wasm)
// and a no-op that returns zero values (host_native.go). That is what lets the
// neutral kjol/webui components — which must also compile on the server for SSR —
// call them unconditionally. On the server a measurement is simply the zero Rect,
// a listener is never installed, and a timer never fires; components render their
// pre-measurement state (a panel with `visibility: hidden`), which is exactly what
// SSR should ship.
//
// Two rules for callers:
//
// 1. Measure only after the DOM exists. A ref is empty until the reconciler has
// committed; use AfterRender to run measurement code once the current render
// is on screen.
// 2. Do NOT position through signals. Signal.Set re-renders the whole tree; doing
// that on every scroll frame is pathological. Write positions with SetStyle,
// which mutates the DOM node directly and leaves the vdom alone.
// Rect is an element's box in viewport coordinates — the result of
// getBoundingClientRect. Viewport coordinates compose directly with
// `position: fixed`, so no scroll compensation is needed (or wanted) anywhere.
type Rect struct{ X, Y, Width, Height float64 }
func (r Rect) Top() float64 { return r.Y }
func (r Rect) Left() float64 { return r.X }
func (r Rect) Right() float64 { return r.X + r.Width }
func (r Rect) Bottom() float64 { return r.Y + r.Height }
func (r Rect) CenterX() float64 { return r.X + r.Width/2 }
func (r Rect) CenterY() float64 { return r.Y + r.Height/2 }
// Empty reports whether the rect carries no useful geometry — an unmounted ref, or
// an element that has not been laid out yet. Positioning code must treat this as
// "cannot measure yet" rather than as a box at the origin, or the panel paints at
// 0,0 for a frame.
func (r Rect) Empty() bool { return r.Width == 0 && r.Height == 0 }
// Size is a width/height pair — the viewport, or an element's size.
type Size struct{ Width, Height float64 }
// Unsub removes a listener installed by OnWindow / OnDocument / ObserveResize.
// Always call it when the thing that installed it goes away; a floating panel that
// leaks a window scroll listener per open will crawl.
type Unsub func()
// ScrollBlock values for ScrollIntoView.
const (
ScrollBlockStart = "start"
ScrollBlockCenter = "center"
ScrollBlockEnd = "end"
ScrollBlockNearest = "nearest"
)
// parseCSSLength resolves a CSS length to pixels. rootFontSize is the computed
// font-size of :root (itself a px string), which is what a rem is measured
// against. Unitless and unparseable values yield 0.
func parseCSSLength(value, rootFontSize string) float64 {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
switch {
case strings.HasSuffix(value, "px"):
return parseFloat(strings.TrimSuffix(value, "px"))
case strings.HasSuffix(value, "rem"):
root := parseFloat(strings.TrimSuffix(strings.TrimSpace(rootFontSize), "px"))
if root == 0 {
root = 16 // the browser default, if :root's font-size is unreadable
}
return parseFloat(strings.TrimSuffix(value, "rem")) * root
default:
return parseFloat(value)
}
}
func parseFloat(s string) float64 {
f, err := strconv.ParseFloat(strings.TrimSpace(s), 64)
if err != nil {
return 0
}
return f
}

View File

@@ -0,0 +1,49 @@
//go:build !(js && wasm)
package wasmruntime
import "kjol/vdom"
// The server half of the host API (see host.go). Nothing here touches a browser,
// because there isn't one: measurements are zero, listeners are never installed,
// timers never fire, storage is always empty. Components call these
// unconditionally and render their unmeasured state, which is what SSR ships.
func Measure(*vdom.Ref) Rect { return Rect{} }
func Viewport() Size { return Size{} }
func CSSVarPx(string) float64 { return 0 }
func RAF(func()) int { return 0 }
func CancelRAF(int) {}
func AfterRender(func()) {}
func SetStyle(*vdom.Ref, string, string) {}
func RemoveStyle(*vdom.Ref, string) {}
func SetText(*vdom.Ref, string) {}
func SetHTML(*vdom.Ref, string) {}
func Focus(*vdom.Ref) {}
func Blur(*vdom.Ref) {}
func SelectionRange(*vdom.Ref) (int, int) { return 0, 0 }
func SetSelectionRange(*vdom.Ref, int, int) {}
func ScrollIntoView(*vdom.Ref, bool, string) {}
func ScrollLeft(*vdom.Ref) float64 { return 0 }
func SetScrollLeft(*vdom.Ref, float64) {}
func Contains(*vdom.Ref, any) bool { return false }
func ClosestAttr(any, string, string) (string, bool) { return "", false }
func QuerySelector(string) *vdom.Ref { return vdom.NewRef() }
func OnWindow(string, bool, func(vdom.Event)) Unsub { return func() {} }
func OnDocument(string, bool, func(vdom.Event)) Unsub { return func() {} }
func ObserveResize(*vdom.Ref, func()) Unsub { return func() {} }
func SetTimeout(int, func()) int { return 0 }
func ClearTimeout(int) {}
func StorageGet(string) (string, bool) { return "", false }
func StorageSet(string, string) {}
func StorageRemove(string) {}
func Download(string, string, []byte) {}
func Print(string, []byte) {}

214
go/wasmruntime/host_test.go Normal file
View File

@@ -0,0 +1,214 @@
//go:build js && wasm
package wasmruntime
import (
"strings"
"syscall/js"
"testing"
"kjol/vdom"
)
// Run from kjol/go:
//
// GOOS=js GOARCH=wasm go test -exec="node testdata/domexec.js" ./wasmruntime
// A Ref must be attached when the element is created, survive a diff that reuses
// the element, and be nil again once the element is gone — otherwise measurement
// code silently reads a stale node.
func TestRefLifecycle(t *testing.T) {
root := document.Call("createElement", "div")
panel := vdom.NewRef()
if panel.Mounted() {
t.Fatal("a fresh ref should not be mounted")
}
a := vdom.El("div", vdom.WithRef(panel), vdom.Attr("class", "one"))
patchChildren(root, nil, one(a))
if !panel.Mounted() {
t.Fatal("ref was not attached on create")
}
created, _ := panel.Node().(js.Value)
// A re-render builds a fresh VNode carrying the same *Ref; the diff adopts the
// existing element, so the ref must still point at it.
b := vdom.El("div", vdom.WithRef(panel), vdom.Attr("class", "two"))
patchChildren(root, one(a), one(b))
if !panel.Mounted() {
t.Fatal("ref was detached by a re-render that reused the element")
}
if adopted, _ := panel.Node().(js.Value); !adopted.Equal(created) {
t.Error("ref points at a different node after a reusing diff")
}
// Removing the element must clear the ref.
patchChildren(root, one(b), nil)
if panel.Mounted() {
t.Error("ref still points at a removed element")
}
}
// A tag change replaces the element. createDOM runs before release (replaceChild
// needs both nodes), so a naive detach-on-release would nil the ref that was just
// pointed at the NEW element.
func TestRefSurvivesTagChange(t *testing.T) {
root := document.Call("createElement", "div")
r := vdom.NewRef()
a := vdom.El("div", vdom.WithRef(r))
patchChildren(root, nil, one(a))
b := vdom.El("span", vdom.WithRef(r))
patchChildren(root, one(a), one(b))
if !r.Mounted() {
t.Fatal("ref was cleared when the element changed tag")
}
if got := Measure(r); got != (Rect{}) { // shim reports a zero rect by default
_ = got
}
if !strings.Contains(root.Get("innerHTML").String(), "<span") {
t.Error("replacement element was not rendered")
}
}
// Measure must read the element's box, and must return the zero Rect (not a box at
// the origin) for an unmounted ref, so positioning code can tell "not laid out yet"
// from "genuinely at 0,0".
func TestMeasure(t *testing.T) {
if got := Measure(vdom.NewRef()); !got.Empty() {
t.Errorf("unmounted ref measured as %+v, want the zero Rect", got)
}
root := document.Call("createElement", "div")
r := vdom.NewRef()
patchChildren(root, nil, one(vdom.El("div", vdom.WithRef(r))))
n, _ := r.Node().(js.Value)
rect := n.Get("rect")
rect.Set("left", 10)
rect.Set("top", 20)
rect.Set("width", 100)
rect.Set("height", 40)
got := Measure(r)
want := Rect{X: 10, Y: 20, Width: 100, Height: 40}
if got != want {
t.Fatalf("Measure = %+v, want %+v", got, want)
}
if got.Right() != 110 || got.Bottom() != 60 || got.CenterX() != 60 {
t.Errorf("derived edges wrong: right=%v bottom=%v centerX=%v", got.Right(), got.Bottom(), got.CenterX())
}
if got.Empty() {
t.Error("a measured element reported Empty()")
}
}
// SetStyle writes straight to the DOM, deliberately bypassing the vdom — that is
// what keeps per-frame repositioning from re-rendering the whole app.
func TestSetStyleBypassesVDOM(t *testing.T) {
root := document.Call("createElement", "div")
r := vdom.NewRef()
patchChildren(root, nil, one(vdom.El("div", vdom.WithRef(r))))
SetStyle(r, "top", "42px")
SetStyle(r, "left", "7px")
if got := root.Get("innerHTML").String(); !strings.Contains(got, "left: 7px; top: 42px") {
t.Fatalf("styles not written: %s", got)
}
RemoveStyle(r, "top")
if got := root.Get("innerHTML").String(); strings.Contains(got, "top:") {
t.Errorf("style not removed: %s", got)
}
SetStyle(vdom.NewRef(), "top", "1px") // unmounted: must not panic
}
// The whole point of the portal: children mount into document.body, NOT into the
// parent — so a panel can escape an ancestor's overflow:hidden / transform.
func TestPortalMountsToBody(t *testing.T) {
root := document.Call("createElement", "div")
body := document.Get("body")
before := body.Get("childNodes").Get("length").Int()
tree := vdom.El("div", vdom.Attr("class", "clipped"),
vdom.Portal(vdom.El("div", vdom.Attr("class", "panel"), vdom.Text("floating"))),
)
patchChildren(root, nil, one(tree))
if got := root.Get("innerHTML").String(); strings.Contains(got, "floating") {
t.Errorf("portal content rendered in-flow instead of at body level:\n%s", got)
}
if got := root.Get("innerHTML").String(); !strings.Contains(got, "data-portal") {
t.Errorf("portal placeholder missing from the tree (sibling indexes will drift):\n%s", got)
}
if body.Get("childNodes").Get("length").Int() != before+1 {
t.Fatal("portal container was not appended to body")
}
container := body.Get("childNodes").Index(before)
if got := container.Get("innerHTML").String(); !strings.Contains(got, "floating") {
t.Fatalf("portal children not in the body container: %s", got)
}
// Unmounting the portal must take the body-level container with it, or the
// panel outlives the component that opened it.
patchChildren(root, one(tree), nil)
if body.Get("childNodes").Get("length").Int() != before {
t.Error("portal container leaked into body after unmount")
}
}
// A portal's children must diff against the body container across re-renders, not
// be recreated or appended to the placeholder.
func TestPortalPatchesChildrenInPlace(t *testing.T) {
root := document.Call("createElement", "div")
body := document.Get("body")
before := body.Get("childNodes").Get("length").Int()
a := vdom.El("div", vdom.Portal(vdom.El("p", vdom.Text("first"))))
patchChildren(root, nil, one(a))
container := body.Get("childNodes").Index(before)
b := vdom.El("div", vdom.Portal(vdom.El("p", vdom.Text("second"))))
patchChildren(root, one(a), one(b))
if body.Get("childNodes").Get("length").Int() != before+1 {
t.Fatal("re-render created a second portal container")
}
got := container.Get("innerHTML").String()
if !strings.Contains(got, "second") || strings.Contains(got, "first") {
t.Errorf("portal children not patched in place: %s", got)
}
patchChildren(root, one(b), nil)
}
// Outside-click detection is built on these two.
func TestContainsAndClosestAttr(t *testing.T) {
root := document.Call("createElement", "div")
panel := vdom.NewRef()
inner := vdom.NewRef()
tree := vdom.El("div", vdom.Attr("data-floating-id", "menu-1"), vdom.WithRef(panel),
vdom.El("button", vdom.WithRef(inner), vdom.Text("item")),
)
patchChildren(root, nil, one(tree))
target := inner.Node()
if !Contains(panel, target) {
t.Error("Contains missed a descendant")
}
other := vdom.NewRef()
patchChildren(root, nil, one(vdom.El("div", vdom.WithRef(other))))
if Contains(other, target) {
t.Error("Contains matched an unrelated element")
}
id, ok := ClosestAttr(target, "[data-floating-id]", "data-floating-id")
if !ok || id != "menu-1" {
t.Errorf("ClosestAttr = %q, %v; want \"menu-1\", true", id, ok)
}
if _, ok := ClosestAttr(nil, "[data-floating-id]", "data-floating-id"); ok {
t.Error("ClosestAttr should report not-found for a nil target")
}
}

438
go/wasmruntime/host_wasm.go Normal file
View File

@@ -0,0 +1,438 @@
//go:build js && wasm
package wasmruntime
import (
"syscall/js"
"kjol/vdom"
)
// The browser half of the host API (see host.go for the contract and the two
// rules for callers).
var window = js.Global()
// node resolves a Ref to its live DOM handle, or an invalid js.Value if the ref
// is unattached. Every entry point below guards on this, so calling a host
// function against an unmounted ref is a silent no-op rather than a panic — the
// common case during the first render, before the reconciler has committed.
func node(r *vdom.Ref) (js.Value, bool) {
if r == nil {
return js.Value{}, false
}
n, ok := r.Node().(js.Value)
if !ok || !n.Truthy() {
return js.Value{}, false
}
return n, true
}
// ---- measurement ----
// Measure runs getBoundingClientRect on an element. Returns the zero Rect if the
// ref is not mounted — callers must check Rect.Empty() before positioning against
// it (see host.go), or the panel lands at 0,0.
func Measure(r *vdom.Ref) Rect {
n, ok := node(r)
if !ok {
return Rect{}
}
b := n.Call("getBoundingClientRect")
return Rect{
X: b.Get("left").Float(),
Y: b.Get("top").Float(),
Width: b.Get("width").Float(),
Height: b.Get("height").Float(),
}
}
// Viewport is window.innerWidth/innerHeight — the collision boundary for the
// floating engine.
func Viewport() Size {
return Size{
Width: window.Get("innerWidth").Float(),
Height: window.Get("innerHeight").Float(),
}
}
// CSSVarPx reads a CSS custom property off :root and resolves it to pixels,
// handling rem (against the root font size) and px. Returns 0 if unset or
// unparseable. Used for --radius-default, so the tutorial spotlight's corners
// match the app's theme.
func CSSVarPx(name string) float64 {
root := document.Get("documentElement")
style := window.Call("getComputedStyle", root)
raw := style.Call("getPropertyValue", name).String()
return parseCSSLength(raw, style.Get("fontSize").String())
}
// ---- frame timing + the post-render hook ----
func RAF(fn func()) int {
var cb js.Func
cb = js.FuncOf(func(js.Value, []js.Value) any {
cb.Release()
fn()
return nil
})
return window.Call("requestAnimationFrame", cb).Int()
}
func CancelRAF(id int) { window.Call("cancelAnimationFrame", id) }
// afterRender holds callbacks queued for the next render commit. See mount.go,
// which drains it once the DOM is up to date.
var afterRender []func()
// AfterRender runs fn once, right after the current render has been committed to
// the DOM. This is how a component measures something it just rendered: signal
// writes only *schedule* a render, so a ref read in the same tick is still empty.
//
// open.Set(true)
// wasmruntime.AfterRender(func() { reposition() }) // the panel exists by now
//
// If no render is pending (nothing changed), fn still runs — on the next frame —
// so a caller can never be stranded waiting for a commit that will not come.
func AfterRender(fn func()) {
afterRender = append(afterRender, fn)
if !renderScheduled {
// No re-render coming; run on the next frame so the caller's contract
// ("after the DOM is settled") still holds.
RAF(flushAfterRender)
}
}
func flushAfterRender() {
if len(afterRender) == 0 {
return
}
pending := afterRender
afterRender = nil
for _, fn := range pending {
fn()
}
}
// ---- imperative DOM writes (bypassing the vdom on purpose) ----
// SetStyle writes an inline style property directly on the element. Positioning
// goes through here rather than through a signal: a signal write re-renders the
// entire tree, and repositioning happens on every scroll and resize frame.
func SetStyle(r *vdom.Ref, prop, value string) {
if n, ok := node(r); ok {
n.Get("style").Call("setProperty", prop, value)
}
}
func RemoveStyle(r *vdom.Ref, prop string) {
if n, ok := node(r); ok {
n.Get("style").Call("removeProperty", prop)
}
}
// SetText / SetHTML write content imperatively. SetHTML exists for the formula
// editor's syntax-highlight overlay, which is repainted per keystroke and must not
// go through a full re-render.
func SetText(r *vdom.Ref, text string) {
if n, ok := node(r); ok {
n.Set("textContent", text)
}
}
func SetHTML(r *vdom.Ref, html string) {
if n, ok := node(r); ok {
n.Set("innerHTML", html)
}
}
// ---- focus, selection, scrolling ----
func Focus(r *vdom.Ref) {
if n, ok := node(r); ok {
n.Call("focus")
}
}
func Blur(r *vdom.Ref) {
if n, ok := node(r); ok {
n.Call("blur")
}
}
// SelectionRange / SetSelectionRange expose the caret in an <input>/<textarea> —
// what the formula editor needs to insert a function at the cursor.
func SelectionRange(r *vdom.Ref) (start, end int) {
n, ok := node(r)
if !ok {
return 0, 0
}
return n.Get("selectionStart").Int(), n.Get("selectionEnd").Int()
}
func SetSelectionRange(r *vdom.Ref, start, end int) {
if n, ok := node(r); ok {
n.Call("setSelectionRange", start, end)
}
}
func ScrollIntoView(r *vdom.Ref, smooth bool, block string) {
n, ok := node(r)
if !ok {
return
}
behavior := "auto"
if smooth {
behavior = "smooth"
}
if block == "" {
block = ScrollBlockCenter
}
opts := js.Global().Get("Object").New()
opts.Set("behavior", behavior)
opts.Set("block", block)
n.Call("scrollIntoView", opts)
}
func ScrollLeft(r *vdom.Ref) float64 {
n, ok := node(r)
if !ok {
return 0
}
return n.Get("scrollLeft").Float()
}
func SetScrollLeft(r *vdom.Ref, x float64) {
if n, ok := node(r); ok {
n.Set("scrollLeft", x)
}
}
// ---- hit testing (outside-click) ----
// Contains reports whether target lies inside r's subtree. target is an
// Event.Target(). This is how a floating panel decides a click was "outside".
func Contains(r *vdom.Ref, target any) bool {
n, ok := node(r)
if !ok {
return false
}
t, ok := target.(js.Value)
if !ok || !t.Truthy() {
return false
}
return n.Call("contains", t).Bool()
}
// ClosestAttr walks up from target to the nearest ancestor matching selector and
// returns that element's attr. It returns the attribute *value* rather than a
// handle on purpose: the floating layer identifies panels by string id, so Go can
// compare against its own open-order stack without needing JS object identity.
func ClosestAttr(target any, selector, attr string) (string, bool) {
t, ok := target.(js.Value)
if !ok || !t.Truthy() {
return "", false
}
// target may be a text node (clicks land on text); closest() is an Element
// method, so climb to the nearest element first.
if t.Get("nodeType").Int() != 1 {
t = t.Get("parentElement")
if !t.Truthy() {
return "", false
}
}
el := t.Call("closest", selector)
if !el.Truthy() {
return "", false
}
v := el.Call("getAttribute", attr)
if !v.Truthy() {
return "", false
}
return v.String(), true
}
// QuerySelector finds an existing element anywhere in the document — for the
// tutorial, whose steps target app elements it does not own.
func QuerySelector(selector string) *vdom.Ref {
r := vdom.NewRef()
if el := document.Call("querySelector", selector); el.Truthy() {
vdom.SetRefNode(r, el)
}
return r
}
// ---- global listeners ----
// OnWindow installs a window-level listener and returns its remover.
//
// capture matters: `scroll` does not bubble, so a capture-phase listener on
// window is the only way to hear scrolling inside a nested scroll container — a
// floating panel anchored to a row in a scrollable table depends on it.
func OnWindow(event string, capture bool, fn func(vdom.Event)) Unsub {
return listen(window, event, capture, fn)
}
// OnDocument installs a document-level listener and returns its remover. Used for
// outside-click (mousedown, which fires before focus moves) and Escape (keydown).
func OnDocument(event string, capture bool, fn func(vdom.Event)) Unsub {
return listen(document, event, capture, fn)
}
func listen(target js.Value, event string, capture bool, fn func(vdom.Event)) Unsub {
cb := js.FuncOf(func(_ js.Value, args []js.Value) any {
var ev js.Value
if len(args) > 0 {
ev = args[0]
}
fn(clientEvent{js: ev})
return nil
})
target.Call("addEventListener", event, cb, capture)
return func() {
target.Call("removeEventListener", event, cb, capture)
cb.Release()
}
}
// ObserveResize fires fn whenever the element's own size changes. This is the fix
// for a real gap in the original TSX kit, which repositioned floating panels only
// on scroll and window resize — so a panel whose *content* grew (an async-loaded
// list, a filtered dropdown) stayed at its stale position.
func ObserveResize(r *vdom.Ref, fn func()) Unsub {
n, ok := node(r)
if !ok {
return func() {}
}
ctor := window.Get("ResizeObserver")
if !ctor.Truthy() {
return func() {}
}
cb := js.FuncOf(func(js.Value, []js.Value) any { fn(); return nil })
obs := ctor.New(cb)
obs.Call("observe", n)
return func() {
obs.Call("disconnect")
cb.Release()
}
}
// ---- timers ----
// SetTimeout schedules fn. The hover bridge (moving the cursor from a trigger onto
// its panel across a gap without the panel vanishing) is built on these.
func SetTimeout(ms int, fn func()) int {
var cb js.Func
cb = js.FuncOf(func(js.Value, []js.Value) any {
cb.Release()
fn()
return nil
})
return window.Call("setTimeout", cb, ms).Int()
}
func ClearTimeout(id int) {
if id != 0 {
window.Call("clearTimeout", id)
}
}
// ---- localStorage ----
// StorageGet/Set/Remove wrap localStorage. Every call is guarded: localStorage
// throws in private-mode Safari and when storage is full, and a table remembering
// its column widths is never worth taking the app down for.
func StorageGet(key string) (string, bool) {
ls := window.Get("localStorage")
if !ls.Truthy() {
return "", false
}
v := tryCall(ls, "getItem", key)
if !v.Truthy() {
return "", false
}
return v.String(), true
}
func StorageSet(key, value string) {
if ls := window.Get("localStorage"); ls.Truthy() {
tryCall(ls, "setItem", key, value)
}
}
func StorageRemove(key string) {
if ls := window.Get("localStorage"); ls.Truthy() {
tryCall(ls, "removeItem", key)
}
}
// tryCall invokes a JS method, swallowing a thrown exception (js.Value.Call panics
// on throw) and returning undefined instead.
func tryCall(recv js.Value, method string, args ...any) (out js.Value) {
defer func() {
if recover() != nil {
out = js.Undefined()
}
}()
return recv.Call(method, args...)
}
// ---- getting bytes out of wasm ----
// Download hands bytes to the browser as a file: Blob -> object URL -> a synthetic
// <a download> click -> revoke. This is the only way out of wasm for a generated
// CSV or PDF.
func Download(filename, mime string, data []byte) {
url := blobURL(mime, data)
defer window.Get("URL").Call("revokeObjectURL", url)
a := document.Call("createElement", "a")
a.Set("href", url)
a.Set("download", filename)
document.Get("body").Call("appendChild", a)
a.Call("click")
document.Get("body").Call("removeChild", a)
}
// Print loads bytes into a hidden iframe and opens the browser's print dialog on
// it — how a generated PDF gets printed without first being saved.
func Print(mime string, data []byte) {
url := blobURL(mime, data)
frame := document.Call("createElement", "iframe")
frame.Get("style").Call("setProperty", "display", "none")
frame.Set("src", url)
var onload js.Func
onload = js.FuncOf(func(js.Value, []js.Value) any {
defer onload.Release()
w := frame.Get("contentWindow")
if w.Truthy() {
w.Call("focus")
w.Call("print")
}
// The iframe must outlive the dialog, so the URL is revoked on a delay
// rather than immediately: revoking it while the dialog is open blanks the
// preview in Chrome.
SetTimeout(60_000, func() {
window.Get("URL").Call("revokeObjectURL", url)
if frame.Get("parentNode").Truthy() {
document.Get("body").Call("removeChild", frame)
}
})
return nil
})
frame.Set("onload", onload)
document.Get("body").Call("appendChild", frame)
}
func blobURL(mime string, data []byte) string {
buf := js.Global().Get("Uint8Array").New(len(data))
js.CopyBytesToJS(buf, data)
parts := js.Global().Get("Array").New()
parts.Call("push", buf)
opts := js.Global().Get("Object").New()
opts.Set("type", mime)
blob := js.Global().Get("Blob").New(parts, opts)
return window.Get("URL").Call("createObjectURL", blob).String()
}

View File

@@ -0,0 +1,80 @@
//go:build js && wasm
package wasmruntime
import (
"strings"
"syscall/js"
"testing"
"kjol/vdom"
)
// buildServerDOM fakes what the HTML parser hands us: a real DOM tree, built from
// the SERVER's VNode tree, which hydration then adopts.
func buildServerDOM(server *vdom.VNode) js.Value { return createDOM(server, "") }
// Hydration ADOPTS the server's DOM. If the server's HTML and the client's render
// disagree, the disagreement has to be resolved in the CLIENT's favour — it is the
// one that is running, and it is the only one that can still change.
//
// Left unreconciled, the divergence is permanent and invisible: the DOM keeps the
// server's value, and no later re-render corrects it either, because updateAttrs
// compares one client VNode against the next — which agree with each other and
// disagree with the DOM. The symptom is a page that is right when you navigate to it
// and wrong when you refresh onto it.
func TestHydrationReconcilesAttributes(t *testing.T) {
root := document.Call("createElement", "div")
// What the server rendered (say, from a stale binary).
server := vdom.El("main", vdom.Attr("class", "mx-auto max-w-5xl px-4"))
root.Call("appendChild", buildServerDOM(server))
// What the client actually renders now.
client := vdom.El("main", vdom.Attr("class", "mx-auto max-w-[100rem] px-4"))
hydrateNode(root.Get("firstChild"), client)
got := root.Get("firstChild").Call("getAttribute", "class").String()
if got != "mx-auto max-w-[100rem] px-4" {
t.Errorf("after hydration class = %q, want the client's value — the server's stale class was kept", got)
}
}
// And the ordinary case must still work: matching markup is adopted, not rebuilt.
func TestHydrationAdoptsMatchingMarkup(t *testing.T) {
root := document.Call("createElement", "div")
server := vdom.El("div", vdom.Attr("class", "a"),
vdom.El("span", vdom.Text("hello")),
)
root.Call("appendChild", buildServerDOM(server))
adopted := root.Get("firstChild")
client := vdom.El("div", vdom.Attr("class", "a"),
vdom.El("span", vdom.Text("hello")),
)
hydrateNode(root.Get("firstChild"), client)
// Same node — hydration adopted it rather than replacing it.
if !rt(client).dom.Equal(adopted) {
t.Error("hydration replaced a matching node instead of adopting it")
}
if got := root.Get("innerHTML").String(); !strings.Contains(got, "hello") {
t.Errorf("content lost: %s", got)
}
}
// A text node that disagrees is corrected too (this already worked; pin it).
func TestHydrationCorrectsText(t *testing.T) {
root := document.Call("createElement", "div")
server := vdom.El("p", vdom.Text("stale"))
root.Call("appendChild", buildServerDOM(server))
client := vdom.El("p", vdom.Text("fresh"))
hydrateNode(root.Get("firstChild"), client)
if got := root.Get("innerHTML").String(); !strings.Contains(got, "fresh") {
t.Errorf("stale server text survived hydration: %s", got)
}
}

View File

@@ -32,6 +32,7 @@ func Run(component func() *vdom.VNode) {
next := component()
patchChildren(root, one(prev), one(next))
prev = next
flushAfterRender() // refs are live now — measurement callbacks can run
}
rootRender()
finish(root)
@@ -49,7 +50,9 @@ func Hydrate(component func() *vdom.VNode) {
next := component()
patchChildren(root, one(prev), one(next))
prev = next
flushAfterRender()
}
flushAfterRender() // the adopted DOM is live; refs from hydration are usable
finish(root)
}

View File

@@ -18,6 +18,9 @@ type nodeRT struct {
dom js.Value
jsFuncs map[string]js.Func
refs map[string]*handlerRef
// portal is the body-level container holding a TagPortal node's children. The
// `dom` field is the hidden in-flow placeholder that marks its slot in the tree.
portal js.Value
}
type handlerRef struct{ fn func(vdom.Event) }
@@ -33,6 +36,7 @@ func rt(n *vdom.VNode) *nodeRT {
type clientEvent struct{ js js.Value }
func (e clientEvent) PreventDefault() { e.js.Call("preventDefault") }
func (e clientEvent) StopPropagation() { e.js.Call("stopPropagation") }
func (e clientEvent) Value() string {
t := e.js.Get("target")
@@ -46,6 +50,55 @@ func (e clientEvent) Value() string {
return v.String()
}
func (e clientEvent) Checked() bool {
t := e.js.Get("target")
if !t.Truthy() {
return false
}
return t.Get("checked").Truthy()
}
func (e clientEvent) Key() string {
k := e.js.Get("key")
if !k.Truthy() {
return ""
}
return k.String()
}
func (e clientEvent) ClientX() int { return coord(e.js, "clientX") }
func (e clientEvent) ClientY() int { return coord(e.js, "clientY") }
func coord(ev js.Value, prop string) int {
v := ev.Get(prop)
if v.Type() != js.TypeNumber {
return 0
}
return v.Int()
}
func (e clientEvent) Target() any {
t := e.js.Get("target")
if !t.Truthy() {
return nil
}
return t
}
func (e clientEvent) SetData(format, data string) {
if dt := e.js.Get("dataTransfer"); dt.Truthy() {
dt.Call("setData", format, data)
}
}
func (e clientEvent) GetData(format string) string {
dt := e.js.Get("dataTransfer")
if !dt.Truthy() {
return ""
}
return dt.Call("getData", format).String()
}
func one(n *vdom.VNode) []*vdom.VNode {
if n == nil {
return nil
@@ -55,14 +108,57 @@ func one(n *vdom.VNode) []*vdom.VNode {
// ---- fresh create + diff ----
func createDOM(n *vdom.VNode) js.Value {
// SVG lives in its own XML namespace, and an element's namespace is fixed at
// CREATION — you cannot fix it afterwards with an attribute.
//
// document.createElement("svg") does NOT make an SVG element; it makes an
// HTMLUnknownElement that happens to be spelled "svg". It has no geometry, it
// renders nothing, and its children are inert. Everything still *looks* right in the
// DOM inspector, which is what makes this so easy to miss.
//
// The reason it only broke on NAVIGATION: a server-rendered page's icons come from
// the HTML parser, which handles <svg> as foreign content and gets the namespace
// right, and hydration merely adopts those nodes. Only elements the client CREATES —
// i.e. everything rendered after the first paint — went through createElement and
// came out inert. Hence: icons fine on load, gone as soon as you navigate.
const svgNamespace = "http://www.w3.org/2000/svg"
// elementNS returns the namespace an element and its subtree belong to. Nested
// <svg> switches into the SVG namespace; <foreignObject> switches back to HTML.
func elementNS(tag, parentNS string) string {
switch tag {
case "svg":
return svgNamespace
case "foreignObject":
return "" // HTML content inside SVG
}
return parentNS
}
func createElement(tag, ns string) js.Value {
if ns == "" {
return document.Call("createElement", tag)
}
return document.Call("createElementNS", ns, tag)
}
// createDOM builds a node. ns is the namespace inherited from the parent element:
// "" for ordinary HTML, svgNamespace inside an <svg>.
func createDOM(n *vdom.VNode, ns string) js.Value {
if n.Tag == "" {
d := document.Call("createTextNode", n.Text)
rt(n).dom = d
vdom.SetRefNode(n.Ref, d)
return d
}
el := document.Call("createElement", n.Tag)
if n.Tag == vdom.TagPortal {
return createPortal(n)
}
ns = elementNS(n.Tag, ns)
el := createElement(n.Tag, ns)
rt(n).dom = el
vdom.SetRefNode(n.Ref, el)
for k, v := range n.Attrs {
el.Call("setAttribute", k, v)
}
@@ -77,12 +173,57 @@ func createDOM(n *vdom.VNode) js.Value {
return el
}
for _, c := range n.Children {
el.Call("appendChild", createDOM(c))
el.Call("appendChild", createDOM(c, ns))
}
return el
}
// createPortal builds the two halves of a TagPortal node: a hidden placeholder
// that holds its slot in the tree (so sibling indexes — and hydration — still line
// up), and a container appended to document.body that the children actually render
// into. Returning the placeholder is what keeps the caller's appendChild correct.
func createPortal(n *vdom.VNode) js.Value {
r := rt(n)
placeholder := document.Call("createElement", "div")
placeholder.Call("setAttribute", "data-portal", "")
placeholder.Get("style").Call("setProperty", "display", "none")
r.dom = placeholder
r.portal = newPortalContainer()
vdom.SetRefNode(n.Ref, r.portal)
for _, c := range n.Children {
// The container is a plain <div> on document.body, so its children start in
// the HTML namespace however deeply the portal was nested.
r.portal.Call("appendChild", createDOM(c, ""))
}
return placeholder
}
func newPortalContainer() js.Value {
c := document.Call("createElement", "div")
c.Call("setAttribute", "data-portal-container", "")
document.Get("body").Call("appendChild", c)
return c
}
// nsOf reads an existing element's namespace, so anything created beneath it
// inherits the right one. Derived from the live DOM rather than threaded through the
// call stack: the parent already knows the answer, and it cannot go stale.
func nsOf(el js.Value) string {
if !el.Truthy() {
return ""
}
uri := el.Get("namespaceURI")
if uri.Truthy() && uri.String() == svgNamespace {
return svgNamespace
}
return "" // HTML (or a text node / detached node)
}
// patchChildren diffs a child list against `parent`'s children.
func patchChildren(parent js.Value, old, next []*vdom.VNode) {
ns := nsOf(parent) // read once: every child of this parent shares it
n := max(len(old), len(next))
for i := range n {
var o, x *vdom.VNode
@@ -92,21 +233,21 @@ func patchChildren(parent js.Value, old, next []*vdom.VNode) {
if i < len(next) {
x = next[i]
}
patch(parent, o, x)
patch(parent, o, x, ns)
}
}
func patch(parent js.Value, o, x *vdom.VNode) {
func patch(parent js.Value, o, x *vdom.VNode, ns string) {
switch {
case o == nil && x == nil:
return
case o == nil:
parent.Call("appendChild", createDOM(x))
parent.Call("appendChild", createDOM(x, ns))
case x == nil:
parent.Call("removeChild", rt(o).dom)
release(o)
case o.Tag != x.Tag:
parent.Call("replaceChild", createDOM(x), rt(o).dom)
parent.Call("replaceChild", createDOM(x, ns), rt(o).dom)
release(o)
default:
x.Runtime = o.Runtime // adopt dom + listeners
@@ -115,9 +256,18 @@ func patch(parent js.Value, o, x *vdom.VNode) {
// o was a hydration hole (no adopted DOM node, e.g. a server/client
// markup mismatch). Recreate this node fresh instead of calling into an
// undefined DOM handle.
parent.Call("appendChild", createDOM(x))
parent.Call("appendChild", createDOM(x, ns))
return
}
if x.Tag == vdom.TagPortal {
// The placeholder stays put; the children diff against the body-level
// container, not against `parent`.
vdom.SetRefNode(x.Ref, rt(x).portal)
patchChildren(rt(x).portal, o.Children, x.Children)
return
}
// x is a fresh VNode each render, so re-point its ref at the adopted node.
vdom.SetRefNode(x.Ref, dom)
if x.Tag == "" {
if x.Text != o.Text {
dom.Set("nodeValue", x.Text)
@@ -209,15 +359,43 @@ func addListener(n *vdom.VNode, name string, handler func(vdom.Event)) {
func release(n *vdom.VNode) {
if n.Runtime != nil {
for _, fn := range rt(n).jsFuncs {
r := rt(n)
for _, fn := range r.jsFuncs {
fn.Release()
}
// A portal's children live at body level, so removing the placeholder from
// the tree does not remove them — the container has to go explicitly, or the
// panel outlives the component that opened it.
if r.portal.Truthy() {
if p := r.portal.Get("parentNode"); p.Truthy() {
p.Call("removeChild", r.portal)
}
}
detachRef(n, r)
}
for _, c := range n.Children {
release(c)
}
}
// detachRef nils out the node's Ref — but only if the ref still points at the node
// being released. On a tag change the reconciler creates the replacement *before*
// releasing the old node (replaceChild needs both), and a component holds one Ref
// across renders, so an unconditional detach here would nil the ref that createDOM
// had just pointed at the new element.
func detachRef(n *vdom.VNode, r *nodeRT) {
if n.Ref == nil {
return
}
cur, ok := n.Ref.Node().(js.Value)
if !ok {
return
}
if cur.Equal(r.dom) || (r.portal.Truthy() && cur.Equal(r.portal)) {
vdom.SetRefNode(n.Ref, nil)
}
}
// ---- hydration: adopt server-rendered DOM instead of creating it ----
func hydrateNode(dom js.Value, n *vdom.VNode) {
@@ -225,6 +403,18 @@ func hydrateNode(dom js.Value, n *vdom.VNode) {
return // structural mismatch; leave a hole (a later re-render will fix)
}
rt(n).dom = dom
vdom.SetRefNode(n.Ref, dom)
if n.Tag == vdom.TagPortal {
// SSR emitted the placeholder and nothing else (the server does not own
// document.body), so adopt the placeholder but build the children fresh.
r := rt(n)
r.portal = newPortalContainer()
vdom.SetRefNode(n.Ref, r.portal)
for _, c := range n.Children {
r.portal.Call("appendChild", createDOM(c, ""))
}
return
}
if n.Tag == "" {
if dom.Get("nodeValue").String() != n.Text {
dom.Set("nodeValue", n.Text)
@@ -234,6 +424,22 @@ func hydrateNode(dom js.Value, n *vdom.VNode) {
for name, h := range n.Events {
addListener(n, name, h)
}
// Reconcile attributes against the client's tree. Hydration ADOPTS the server's
// DOM, so without this any disagreement between the server's HTML and the client's
// render is baked in permanently: the DOM keeps the server's value, and no later
// re-render fixes it either, because updateAttrs compares one client VNode against
// the next — both of which agree with each other and disagree with the DOM.
//
// The client is the source of truth once it is running. It is also the only one of
// the two that can be out of date in the other direction (a stale server binary,
// an SSR cache), and a silently-wrong class is far worse than a redundant
// setAttribute on first paint.
for k, v := range n.Attrs {
if dom.Call("getAttribute", k).String() != v {
dom.Call("setAttribute", k, v)
}
}
for k, v := range n.Props {
dom.Set(k, v)
}

View File

@@ -0,0 +1,41 @@
//go:build js && wasm
package wasmruntime
import (
"strings"
"testing"
"kjol/vdom"
)
// Floating.Panel and Modal both declare their initial inline style in the style
// ATTRIBUTE, then overwrite top/left/opacity imperatively with SetStyle. That only
// works because the reconciler skips setAttribute when the declared value has not
// changed. If that ever stops being true, every floating panel silently snaps back
// to visibility:hidden at 0,0 on the next re-render. Pin it.
func TestImperativeStylesSurviveReRender(t *testing.T) {
root := document.Call("createElement", "div")
panel := vdom.NewRef()
const declared = "position:fixed;top:0;left:0;visibility:hidden"
a := vdom.El("div", vdom.WithRef(panel), vdom.Attr("style", declared), vdom.Text("one"))
patchChildren(root, nil, one(a))
// Position it, the way Reposition does.
SetStyle(panel, "top", "120px")
SetStyle(panel, "left", "40px")
SetStyle(panel, "visibility", "visible")
// A re-render with the SAME declared style but different content.
b := vdom.El("div", vdom.WithRef(panel), vdom.Attr("style", declared), vdom.Text("two"))
patchChildren(root, one(a), one(b))
got := root.Get("innerHTML").String()
if !strings.Contains(got, "two") {
t.Fatalf("content did not update: %s", got)
}
if !strings.Contains(got, "top: 120px") || !strings.Contains(got, "visibility: visible") {
t.Errorf("imperative position was clobbered by the re-render:\n%s", got)
}
}

View File

@@ -0,0 +1,91 @@
//go:build js && wasm
package wasmruntime
import (
"testing"
"kjol/vdom"
)
// An <svg> built with document.createElement() is NOT an SVG element — it is an
// HTMLUnknownElement that happens to be spelled "svg". It has no geometry and it
// draws nothing, while still looking perfectly correct in the DOM inspector.
//
// This only bit on NAVIGATION: a server-rendered page's icons come from the HTML
// parser, which handles <svg> as foreign content correctly, and hydration merely
// adopts those nodes. Every icon the CLIENT created afterwards was inert.
func TestSVGIsCreatedInTheSVGNamespace(t *testing.T) {
root := document.Call("createElement", "div")
icon := vdom.El("svg",
vdom.Attr("viewBox", "0 0 24 24"),
vdom.El("path", vdom.Attr("d", "M4 4h16")),
)
patchChildren(root, nil, one(icon))
svg := root.Get("childNodes").Index(0)
if got := svg.Get("namespaceURI").String(); got != svgNamespace {
t.Fatalf("<svg> namespace = %q, want %q — it will render nothing", got, svgNamespace)
}
// The subtree inherits it: a <path> in the HTML namespace draws nothing either.
path := svg.Get("childNodes").Index(0)
if got := path.Get("namespaceURI").String(); got != svgNamespace {
t.Errorf("<path> namespace = %q, want %q", got, svgNamespace)
}
}
// Ordinary HTML must NOT end up in the SVG namespace, including siblings that follow
// an icon.
func TestHTMLStaysInTheHTMLNamespace(t *testing.T) {
root := document.Call("createElement", "div")
tree := vdom.El("div",
vdom.El("svg", vdom.El("path")),
vdom.El("span", vdom.Text("after the icon")),
)
patchChildren(root, nil, one(tree))
div := root.Get("childNodes").Index(0)
span := div.Get("childNodes").Index(1)
if got := span.Get("namespaceURI").String(); got == svgNamespace {
t.Errorf("<span> after an <svg> leaked into the SVG namespace")
}
}
// The navigation case, end to end: page A's tree is replaced by page B's, and the
// icon page B creates mid-diff must still be a real SVG. This is the one that broke.
func TestIconCreatedDuringNavigationIsRealSVG(t *testing.T) {
root := document.Call("createElement", "div")
pageA := vdom.El("div", vdom.El("p", vdom.Text("no icons here")))
patchChildren(root, nil, one(pageA))
// Navigate: same tag at the same index, so the diff reuses the <div> and creates
// the icon *underneath* it rather than from a fresh mount.
pageB := vdom.El("div", vdom.El("svg", vdom.Attr("viewBox", "0 0 24 24"), vdom.El("path")))
patchChildren(root, one(pageA), one(pageB))
svg := root.Get("childNodes").Index(0).Get("childNodes").Index(0)
if got := svg.Get("tag").String(); got != "svg" {
t.Fatalf("expected an <svg>, got <%s>", got)
}
if got := svg.Get("namespaceURI").String(); got != svgNamespace {
t.Errorf("icon created during navigation has namespace %q, want %q — this is the bug", got, svgNamespace)
}
}
// A <foreignObject> switches back to HTML for its contents.
func TestForeignObjectReturnsToHTML(t *testing.T) {
root := document.Call("createElement", "div")
tree := vdom.El("svg", vdom.El("foreignObject", vdom.El("div", vdom.Text("html again"))))
patchChildren(root, nil, one(tree))
fo := root.Get("childNodes").Index(0).Get("childNodes").Index(0)
inner := fo.Get("childNodes").Index(0)
if got := inner.Get("namespaceURI").String(); got == svgNamespace {
t.Error("content inside <foreignObject> should be HTML, not SVG")
}
}

View File

@@ -13,17 +13,57 @@
const { execSync } = require("child_process");
class CSSStyle {
constructor() { this.props = {}; }
setProperty(k, v) { this.props[k] = String(v); }
removeProperty(k) { delete this.props[k]; }
getPropertyValue(k) { return this.props[k] ?? ""; }
get cssText() {
return Object.keys(this.props).sort().map((k) => `${k}: ${this.props[k]}`).join("; ");
}
}
const XHTML_NS = "http://www.w3.org/1999/xhtml";
const SVG_NS = "http://www.w3.org/2000/svg";
class DNode {
constructor(tag) {
constructor(tag, ns = XHTML_NS) {
this.tag = tag;
// An element's namespace is fixed at creation. createElement() always yields
// HTML — which is why an <svg> built that way is inert; see the reconciler.
this.namespaceURI = ns;
this.childNodes = [];
this.attrs = {};
this.parentNode = null;
this.listeners = {};
this.nodeValue = null;
this.rawHTML = null;
this.style = new CSSStyle();
// Tests set .rect to control what getBoundingClientRect reports; there is no
// layout engine here, so geometry is whatever the test declares.
this.rect = { left: 0, top: 0, width: 0, height: 0 };
}
get nodeType() { return this.tag === "#text" ? 3 : 1; }
get firstChild() { return this.childNodes[0] ?? null; }
get parentElement() { return this.parentNode; }
getAttribute(k) { return k in this.attrs ? this.attrs[k] : null; }
hasAttribute(k) { return k in this.attrs; }
getBoundingClientRect() {
const r = this.rect;
return { left: r.left, top: r.top, width: r.width, height: r.height, right: r.left + r.width, bottom: r.top + r.height };
}
contains(other) {
for (let n = other; n; n = n.parentNode) if (n === this) return true;
return false;
}
// Only the attribute-presence selectors the runtime actually uses, e.g.
// "[data-floating-id]".
closest(selector) {
const m = /^\[([a-zA-Z0-9-]+)\]$/.exec(selector);
if (!m) throw new Error(`domexec shim: unsupported selector ${selector}`);
for (let n = this; n; n = n.parentNode) if (n.nodeType === 1 && n.hasAttribute(m[1])) return n;
return null;
}
appendChild(c) { c.parentNode = this; this.childNodes.push(c); return c; }
removeChild(c) {
const i = this.childNodes.indexOf(c);
@@ -66,15 +106,37 @@ function serialize(n) {
if (n.tag === "#text") return n.nodeValue ?? "";
if (n.tag === "#raw") return n.rawHTML ?? "";
const attrs = Object.keys(n.attrs).sort().map((k) => ` ${k}="${n.attrs[k]}"`).join("");
return `<${n.tag}${attrs}>${n.childNodes.map(serialize).join("")}</${n.tag}>`;
const style = n.style.cssText ? ` style="${n.style.cssText}"` : "";
return `<${n.tag}${attrs}${style}>${n.childNodes.map(serialize).join("")}</${n.tag}>`;
}
const body = new DNode("body");
const documentElement = new DNode("html");
globalThis.document = {
createElement: (tag) => new DNode(tag),
body,
documentElement,
createElement: (tag) => new DNode(tag, XHTML_NS),
createElementNS: (ns, tag) => new DNode(tag, ns),
createTextNode: (text) => { const n = new DNode("#text"); n.nodeValue = text; return n; },
getElementById: () => null,
querySelector: () => null,
addEventListener: () => {},
removeEventListener: () => {},
};
// Viewport size the floating engine collides against. Tests override these.
globalThis.innerWidth = 1024;
globalThis.innerHeight = 768;
globalThis.addEventListener ??= () => {};
globalThis.removeEventListener ??= () => {};
globalThis.requestAnimationFrame = (fn) => setTimeout(() => fn(0), 0);
globalThis.cancelAnimationFrame = (id) => clearTimeout(id);
globalThis.getComputedStyle = (el) => ({
getPropertyValue: (k) => el.style.getPropertyValue(k),
fontSize: "16px",
});
// ---- go_js_wasm_exec boilerplate ----
globalThis.require = require;
globalThis.fs = require("fs");

View File

@@ -53,7 +53,7 @@ func AccordionItem(p AccordionItemProps, children ...*vdom.VNode) *vdom.VNode {
p.OnToggle()
}
}),
vdom.El("span", vdom.Attr("class", accordionTitleCls), vdom.Text(p.Title)),
vdom.Span(vdom.Attr("class", accordionTitleCls), vdom.Text(p.Title)),
}
if p.Disabled {
trigger = append(trigger, vdom.Attr("disabled", "disabled"))
@@ -62,17 +62,16 @@ func AccordionItem(p AccordionItemProps, children ...*vdom.VNode) *vdom.VNode {
if p.IsOpen {
chev = "chevron-up"
}
trigger = append(trigger, vdom.El("span",
vdom.Attr("class", accordionIconCls(p.IsOpen)),
trigger = append(trigger, vdom.Span(vdom.Attr("class", accordionIconCls(p.IsOpen)),
Icon(chev, 18, ""),
))
}
mods := []vdom.Mod{vdom.Attr("class", accordionItemCls), vdom.El("button", trigger...)}
mods := []vdom.Mod{vdom.Attr("class", accordionItemCls), vdom.Button(trigger...)}
if p.IsOpen && !p.Disabled {
mods = append(mods, vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", accordionContentCls)}, children)...))
mods = append(mods, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", accordionContentCls)}, children)...))
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}
// AccordionItemData is one entry for Accordion / SingleAccordion.
@@ -101,7 +100,7 @@ func Accordion(items []AccordionItemData, open []bool, onToggle func(int)) *vdom
},
}, item.Content))
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}
// SingleAccordion renders items where at most one is open. openIndex is the open
@@ -127,5 +126,5 @@ func SingleAccordion(items []AccordionItemData, openIndex int, onChange func(int
},
}, item.Content))
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}

View File

@@ -34,8 +34,8 @@ func Alert(color, header string, children ...*vdom.VNode) *vdom.VNode {
}
mods := []vdom.Mod{vdom.Attr("class", cx(alertBase, cc))}
if header != "" {
mods = append(mods, vdom.El("h3", vdom.Attr("class", "font-semibold mb-2"), vdom.Text(header)))
mods = append(mods, vdom.H3(vdom.Attr("class", "font-semibold mb-2"), vdom.Text(header)))
}
mods = append(mods, vdom.El("p", kids([]vdom.Mod{vdom.Attr("class", "text-sm")}, children)...))
return vdom.El("div", mods...)
mods = append(mods, vdom.P(kids([]vdom.Mod{vdom.Attr("class", "text-sm")}, children)...))
return vdom.Div(mods...)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,492 @@
package webui
import (
"reflect"
"strings"
"testing"
)
// Calculated columns, and the distinction this originally got wrong:
//
// - a calculated COLUMN with a predefined function combines its operand columns
// ACROSS THE ROW — sum over [Revenue, Cost] is revenue + cost, per row;
// - a SUMMARY ROW aggregates ONE operand column DOWN the table.
//
// And operands are column KEYS (a column's SortIdentifier), not display names —
// formulas are the ones that use display names, as [Gross revenue].
type calcRec struct {
Name string
Revenue string
Cost string
}
// The display names deliberately differ from the field names, so anything that
// confuses a display name for a key fails loudly instead of passing by luck.
func newCalcTable() *AutoTableState {
s := NewAutoTableState([]AutoTableColumn{
{Key: "name", DisplayName: "Client", SortIdentifier: "Name", CSV: true},
{Key: "revenue", DisplayName: "Gross revenue", SortIdentifier: "Revenue", CSV: true},
{Key: "cost", DisplayName: "Direct cost", SortIdentifier: "Cost", CSV: true},
}, AutoTableStateOptions{})
s.SetRows([]any{
calcRec{"a", "100", "40"},
calcRec{"b", "200", "50"},
})
return s
}
// calcValues formats a calculated column for every row, the way the table does.
func calcValues(t *testing.T, s *AutoTableState, id string) []string {
t.Helper()
s.Render()
rows := s.FilteredRows()
ctx := NewCalcContext(rows, s.Columns(), s.Calculated(), nil)
var uc UserCalculatedColumn
for _, c := range s.Calculated() {
if c.ID == id {
uc = c
}
}
out := make([]string, len(rows))
for i := range rows {
out[i] = FormatCalculatedColumn(uc, ctx.ForRow(i))
}
return out
}
// ---- the model ----
// This is the whole ballgame: sum over Revenue and Cost is 140 and 250 — the row
// sums — NOT the column totals 300 and 90.
func TestCalculatedColumnCombinesOperandsAcrossTheRow(t *testing.T) {
s := newCalcTable()
s.AddCalculated(UserCalculatedColumn{
ID: "total", DisplayName: "Total", Fn: CALC_FN_SUM,
Operands: []string{"Revenue", "Cost"}, // KEYS, not display names
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
})
if got, want := calcValues(t, s, "total"), []string{"140", "250"}; !reflect.DeepEqual(got, want) {
t.Errorf("sum across the row = %v, want %v (a down-column sum would give 300/90)", got, want)
}
}
// subtract and divide are binary AND ORDERED: swapping the operands changes the
// answer, so the order the user picked has to survive.
func TestBinaryCalculatedColumnsRespectOperandOrder(t *testing.T) {
s := newCalcTable()
s.AddCalculated(UserCalculatedColumn{
ID: "profit", DisplayName: "Profit", Fn: CALC_FN_SUBTRACT,
Operands: []string{"Revenue", "Cost"},
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
})
if got, want := calcValues(t, s, "profit"), []string{"60", "150"}; !reflect.DeepEqual(got, want) {
t.Errorf("Revenue - Cost = %v, want %v", got, want)
}
// Reversed, it must become Cost - Revenue.
s.AddCalculated(UserCalculatedColumn{
ID: "profit", DisplayName: "Profit", Fn: CALC_FN_SUBTRACT,
Operands: []string{"Cost", "Revenue"},
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
})
if got, want := calcValues(t, s, "profit"), []string{"-60", "-150"}; !reflect.DeepEqual(got, want) {
t.Errorf("Cost - Revenue = %v, want %v (operand order was lost)", got, want)
}
}
// A summary row is the other direction: one column, aggregated down the table.
func TestSummaryRowAggregatesDownTheColumn(t *testing.T) {
s := newCalcTable()
s.AddSummaryRow(UserSummaryRow{
ID: "total", Label: "Total revenue", Fn: CALC_FN_SUM,
Operands: []string{"Revenue"},
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
})
s.Render()
ctx := NewCalcContext(s.FilteredRows(), s.Columns(), s.Calculated(), nil)
if got := FormatSummaryRow(s.SummaryRows()[0], ctx); got != "300" {
t.Errorf("summary = %s, want 300 (100 + 200, down the column)", got)
}
}
// ---- the editor ----
// The Basic editor must offer operand KEYS. Offering display names works only while
// a display name happens to equal its field name — which is exactly the bug.
func TestEditorOperandOptionsAreKeysNotLabels(t *testing.T) {
s := newCalcTable()
opts := s.operandOptions("")
byLabel := map[string]string{}
for _, o := range opts {
byLabel[o.Label] = o.Key
}
if byLabel["Gross revenue"] != "Revenue" {
t.Errorf("operand for %q = %q, want the key %q", "Gross revenue", byLabel["Gross revenue"], "Revenue")
}
if len(opts) != 3 {
t.Errorf("got %d operand options, want 3", len(opts))
}
}
// A calculated column must not be offered as an operand of itself: that is a cycle,
// and the engine would refuse to evaluate it.
func TestEditorExcludesTheColumnBeingEdited(t *testing.T) {
s := newCalcTable()
s.AddCalculated(UserCalculatedColumn{ID: "profit", DisplayName: "Profit",
Fn: CALC_FN_SUBTRACT, Operands: []string{"Revenue", "Cost"}})
for _, o := range s.operandOptions("profit") {
if o.Key == CalcRef("profit") {
t.Error("the column being edited was offered as an operand of itself")
}
}
// It IS offered when editing something else.
found := false
for _, o := range s.operandOptions("other") {
if o.Key == CalcRef("profit") {
found = true
}
}
if !found {
t.Error("an existing calculated column should be referenceable from another one")
}
}
func TestEditorBasicModeSavesAColumn(t *testing.T) {
s := newCalcTable()
e := s.calcEditorState()
e.name.Set("Total")
e.fn.Set(string(CALC_FN_SUM))
e.operands.Set([]string{"Revenue", "Cost"})
e.precision.Set("0")
if msg := s.calcValidate(e, false); msg != "" {
t.Fatalf("valid form rejected: %s", msg)
}
s.saveCalc(e, false)
if len(s.Calculated()) != 1 {
t.Fatalf("saved %d columns, want 1", len(s.Calculated()))
}
uc := s.Calculated()[0]
if uc.Fn != CALC_FN_SUM || !reflect.DeepEqual(uc.Operands, []string{"Revenue", "Cost"}) {
t.Errorf("saved the wrong spec: %+v", uc)
}
if got, want := calcValues(t, s, uc.ID), []string{"140", "250"}; !reflect.DeepEqual(got, want) {
t.Errorf("the saved column computes %v, want %v", got, want)
}
if e.name.Get() != "" || e.editingID.Get() != "" {
t.Error("the form did not reset after saving")
}
}
// Advanced mode saves fn "custom", with no stale basic spec left beside it — two
// contradictory sources of truth would be worse than either.
func TestEditorAdvancedModeSavesAFormula(t *testing.T) {
s := newCalcTable()
e := s.calcEditorState()
e.name.Set("Margin")
e.advanced.Set(true)
// Formulas reference columns by DISPLAY name, unlike operands.
e.formula.Set("([Gross revenue] - [Direct cost]) / [Gross revenue] * 100")
e.precision.Set("0")
e.operands.Set([]string{"Revenue"}) // left over from basic mode; must be dropped
s.saveCalc(e, false)
uc := s.Calculated()[0]
if uc.Fn != CALC_FN_CUSTOM {
t.Errorf("Fn = %q, want custom", uc.Fn)
}
if len(uc.Operands) != 0 {
t.Errorf("a stale basic operand list survived: %v", uc.Operands)
}
if got, want := calcValues(t, s, uc.ID), []string{"60", "75"}; !reflect.DeepEqual(got, want) {
t.Errorf("margin = %v, want %v", got, want)
}
}
// The operand rules belong to the model, not the form.
func TestEditorValidation(t *testing.T) {
cases := []struct {
name string
setup func(*calcEditor)
want string
}{
{"no name", func(e *calcEditor) { e.operands.Set([]string{"Revenue"}) }, "Enter a name."},
{"no operands", func(e *calcEditor) { e.name.Set("X") }, "Pick at least one column."},
{"binary needs two", func(e *calcEditor) {
e.name.Set("X")
e.fn.Set(string(CALC_FN_SUBTRACT))
e.operands.Set([]string{"Revenue"})
}, "Pick both columns."},
{"empty formula", func(e *calcEditor) {
e.name.Set("X")
e.advanced.Set(true)
}, "Enter a formula."},
{"ok", func(e *calcEditor) {
e.name.Set("X")
e.operands.Set([]string{"Revenue", "Cost"})
}, ""},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
s := newCalcTable()
e := s.calcEditorState()
c.setup(e)
if got := s.calcValidate(e, false); got != c.want {
t.Errorf("calcValidate = %q, want %q", got, c.want)
}
})
}
// A formula that does not compile reports the compiler's own error, and does not
// save: it would add a column of dashes and leave the user with no idea why.
s := newCalcTable()
e := s.calcEditorState()
e.name.Set("X")
e.advanced.Set(true)
e.formula.Set("[Gross revenue] * ")
if s.calcValidate(e, false) == "" {
t.Error("an unparseable formula passed validation")
}
s.saveCalc(e, false)
if len(s.Calculated()) != 0 {
t.Error("an invalid formula was saved anyway")
}
if e.errorMsg.Get() == "" {
t.Error("saving an invalid form set no error message")
}
}
// Editing loads the existing spec and REPLACES it, rather than adding a second one.
func TestEditorEditAndRemove(t *testing.T) {
s := newCalcTable()
e := s.calcEditorState()
e.name.Set("Total")
e.operands.Set([]string{"Revenue", "Cost"})
s.saveCalc(e, false)
id := s.Calculated()[0].ID
s.loadColumn(e, s.Calculated()[0])
if e.editingID.Get() != id || e.advanced.Get() {
t.Fatalf("loadColumn did not restore basic mode: id=%q advanced=%v", e.editingID.Get(), e.advanced.Get())
}
if !reflect.DeepEqual(e.operands.Get(), []string{"Revenue", "Cost"}) {
t.Errorf("operands not loaded: %v", e.operands.Get())
}
e.fn.Set(string(CALC_FN_SUBTRACT))
s.saveCalc(e, false)
if len(s.Calculated()) != 1 {
t.Fatalf("editing added a duplicate: %d columns", len(s.Calculated()))
}
if s.Calculated()[0].Fn != CALC_FN_SUBTRACT {
t.Errorf("the edit was not applied: %+v", s.Calculated()[0])
}
s.RemoveCalculated(id)
if len(s.Calculated()) != 0 {
t.Error("RemoveCalculated left the column behind")
}
}
// Deleting a column the table is SORTED BY must drop the sort — a sort pointing at a
// column that no longer exists silently stops sorting.
func TestRemoveCalculatedDropsItsSort(t *testing.T) {
s := newCalcTable()
s.AddCalculated(UserCalculatedColumn{ID: "profit", DisplayName: "Profit",
Fn: CALC_FN_SUBTRACT, Operands: []string{"Revenue", "Cost"}})
s.ToggleSort(CalcRef("profit"))
if s.OrderBy().Identifier != CalcRef("profit") {
t.Fatal("could not sort by the calculated column")
}
s.RemoveCalculated("profit")
if s.OrderBy().Identifier != "" {
t.Errorf("sort still points at the deleted column: %q", s.OrderBy().Identifier)
}
}
func TestEditorSavesASummaryRow(t *testing.T) {
s := newCalcTable()
e := s.calcEditorState()
e.view.Set("summary")
e.name.Set("Total revenue")
e.fn.Set(string(CALC_FN_SUM))
e.operands.Set([]string{"Revenue"})
e.precision.Set("0")
s.saveCalc(e, true)
if len(s.SummaryRows()) != 1 || len(s.Calculated()) != 0 {
t.Fatalf("summary=%d calculated=%d, want 1 and 0", len(s.SummaryRows()), len(s.Calculated()))
}
s.Render()
ctx := NewCalcContext(s.FilteredRows(), s.Columns(), s.Calculated(), nil)
if got := FormatSummaryRow(s.SummaryRows()[0], ctx); got != "300" {
t.Errorf("summary = %s, want 300", got)
}
}
// A calculated column may reference another one — the engine resolves _calc_<id>
// through the same operand path.
func TestCalculatedColumnCanReferenceAnother(t *testing.T) {
s := newCalcTable()
s.AddCalculated(UserCalculatedColumn{
ID: "profit", DisplayName: "Profit", Fn: CALC_FN_SUBTRACT,
Operands: []string{"Revenue", "Cost"},
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
})
s.AddCalculated(UserCalculatedColumn{
ID: "double", DisplayName: "Double profit", Fn: CALC_FN_SUM,
Operands: []string{CalcRef("profit"), CalcRef("profit")},
DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
})
if got, want := calcValues(t, s, "double"), []string{"120", "300"}; !reflect.DeepEqual(got, want) {
t.Errorf("calc-in-calc = %v, want %v", got, want)
}
}
// ---- syntax highlighting ----
// The highlighter is LEXICAL, not a parse: it has to colour a half-typed formula
// that does not compile yet, which is exactly when the colours earn their keep.
func TestHighlightFormula(t *testing.T) {
cases := []struct {
name string
src string
want []string // fragments that must appear
}{
{"cell ref", "[Revenue]", []string{`<span class="text-sky-600">[Revenue]</span>`}},
{"column ref", "{Revenue}", []string{`<span class="text-violet-600">{Revenue}</span>`}},
{"nested braces stay one span", "{Revenue:1:ROW()}",
[]string{`<span class="text-violet-600">{Revenue:1:ROW()}</span>`}},
{"function", "SUM(",
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
{"function with a space before the paren", "SUM (",
[]string{`<span class="text-emerald-700 font-semibold">SUM</span>`}},
{"number", "12.5", []string{`<span class="text-amber-600">12.5</span>`}},
{"constant", "PI", []string{`<span class="text-amber-600">PI</span>`}},
{"operators", "1 + 2", []string{`<span class="text-neutral-400">+</span>`}},
{"a bare word is not coloured", "Revenue", []string{"Revenue"}},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := HighlightFormula(c.src)
for _, want := range c.want {
if !strings.Contains(got, want) {
t.Errorf("HighlightFormula(%q) = %s\nmissing %s", c.src, got, want)
}
}
})
}
}
// It runs on every keystroke, so it must survive a formula mid-type: an unclosed
// bracket, a trailing operator, a lone brace.
func TestHighlightFormulaToleratesHalfTypedInput(t *testing.T) {
for _, src := range []string{"[Reven", "{Rev", "SUM({Revenue}", "1 +", "((", "[", "{", ""} {
if got := HighlightFormula(src); got == "" && src != "" {
t.Errorf("HighlightFormula(%q) produced nothing", src)
}
}
}
// The overlay is injected as raw HTML, so a column name containing markup must not
// escape into the DOM.
func TestHighlightFormulaEscapesHTML(t *testing.T) {
got := HighlightFormula(`[<script>alert(1)</script>]`)
if strings.Contains(got, "<script>") {
t.Errorf("HighlightFormula did not escape markup: %s", got)
}
if !strings.Contains(got, "&lt;script&gt;") {
t.Errorf("expected escaped markup, got: %s", got)
}
}
// ---- the chooser ----
// A calculated column and a summary row are different objects, so the editor opens
// on a chooser rather than a single form with a switch.
func TestEditorOpensOnTheChooser(t *testing.T) {
s := newCalcTable()
e := s.calcEditorState()
if e.view.Get() != "menu" {
t.Errorf("editor opened on %q, want the chooser", e.view.Get())
}
// The panel's content is inside a portal, whose children the SERVER does not
// render (it does not own document.body) — so exercise the panel builders
// directly rather than through the closed popover.
html := renderNode(s.calcChooser(e))
for _, want := range []string{"Calculated column", "A new column computed for each row", "Summary row", "A total or aggregate shown in the footer"} {
if !strings.Contains(html, want) {
t.Errorf("the chooser is missing %q:\n%s", want, html)
}
}
// The column form and the summary form are different forms, not one form with a
// switch: the column has an Align field and the summary aggregates down a column.
col := renderNode(s.calcForm(e, false))
if !strings.Contains(col, "Column name") || !strings.Contains(col, "Align") {
t.Errorf("the column form is wrong:\n%s", col)
}
sum := renderNode(s.calcForm(e, true))
if !strings.Contains(sum, "Label") || !strings.Contains(sum, "Aggregate down this column") {
t.Errorf("the summary form is wrong:\n%s", sum)
}
if strings.Contains(sum, "Align") {
t.Error("a footer row has no column of its own to align")
}
// Both offer Basic / Advanced.
for _, want := range []string{"Basic", "Advanced"} {
if !strings.Contains(col, want) {
t.Errorf("the form is missing the %q mode", want)
}
}
// Advanced shows the highlighted overlay and the three insert menus.
e.advanced.Set(true)
e.formula.Set("[Gross revenue] * 12")
adv := renderNode(s.calcForm(e, false))
if !strings.Contains(adv, `<span class="text-sky-600">[Gross revenue]</span>`) {
t.Errorf("the formula is not syntax-highlighted:\n%s", adv)
}
for _, want := range []string{"Column", "Function", "Constant"} {
if !strings.Contains(adv, ">"+want+"<") {
t.Errorf("the %q insert menu is missing", want)
}
}
}
// Editing an existing definition jumps straight to the right form, rather than
// making the user pick its kind again.
func TestEditingJumpsToTheRightForm(t *testing.T) {
s := newCalcTable()
e := s.calcEditorState()
s.AddCalculated(UserCalculatedColumn{ID: "c", DisplayName: "C", Fn: CALC_FN_SUM, Operands: []string{"Revenue"}})
s.AddSummaryRow(UserSummaryRow{ID: "r", Label: "R", Fn: CALC_FN_SUM, Operands: []string{"Revenue"}})
s.loadColumn(e, s.Calculated()[0])
if e.view.Get() != "column" {
t.Errorf("editing a column opened view %q, want \"column\"", e.view.Get())
}
s.loadSummary(e, s.SummaryRows()[0])
if e.view.Get() != "summary" {
t.Errorf("editing a summary row opened view %q, want \"summary\"", e.view.Get())
}
// Closing the popover abandons the draft, so reopening starts clean rather than
// resuming a half-written formula the user walked away from.
e.reset()
if e.view.Get() != "menu" || e.editingID.Get() != "" || e.formula.Get() != "" {
t.Error("reset did not return the editor to a clean chooser")
}
}

View File

@@ -0,0 +1,81 @@
package webui
import (
"strings"
"testing"
)
// What a table renders on the SERVER, in each of the three shapes. The distinction
// exists because a saved layout lives in localStorage, which the server cannot read:
//
// - no persistence -> nothing can arrive late. Render in full. (default)
// - persistence -> a skeleton, until the client says what the layout is.
// - persistence + ShowWhile.. -> render the DECLARED table at once, and accept that
// the client may rearrange it (the flash).
func ssrTable(t *testing.T, cols AutoTableColumnOptions) string {
t.Helper()
s := NewAutoTableState([]AutoTableColumn{
{Key: "name", DisplayName: "Client", SortIdentifier: "Name"},
}, AutoTableStateOptions{Columns: cols})
s.SetRows([]any{calcRec{"Ada", "100", "40"}})
return renderNode(s.Render())
}
// A plain, static table — no personalisation — must server-render its CONTENT. This
// is the common case and it must not pay for a feature it does not use.
func TestStaticTableServerRendersInFull(t *testing.T) {
html := ssrTable(t, AutoTableColumnOptions{})
if !strings.Contains(html, "Client") {
t.Errorf("a table with no persistence must SSR its content:\n%s", html)
}
if strings.Contains(html, `aria-busy="true"`) {
t.Error("a table with no persistence rendered a skeleton — it has nothing to wait for")
}
}
// Turn persistence on and the server no longer knows the layout, so it holds a
// skeleton rather than showing a table that is about to rearrange itself.
func TestPersistedTableHoldsASkeletonUntilTheLayoutSettles(t *testing.T) {
html := ssrTable(t, AutoTableColumnOptions{StorageKey: "k"})
if !strings.Contains(html, `aria-busy="true"`) {
t.Errorf("a persisted table should hold a skeleton on the server:\n%s", html)
}
if strings.Contains(html, "Client") {
t.Error("it rendered table content — a user with a saved layout would watch it rearrange")
}
}
// ...unless the caller says it would rather have the content and accept the flash.
func TestShowWhileRestoringServerRendersInFull(t *testing.T) {
html := ssrTable(t, AutoTableColumnOptions{StorageKey: "k", ShowWhileRestoring: true})
if !strings.Contains(html, "Client") {
t.Errorf("ShowWhileRestoring must SSR the declared table:\n%s", html)
}
if strings.Contains(html, `aria-busy="true"`) {
t.Error("ShowWhileRestoring rendered a skeleton anyway")
}
}
// And once the layout has settled, a persisted table renders normally.
func TestPersistedTableRevealsAfterRestore(t *testing.T) {
s := NewAutoTableState([]AutoTableColumn{
{Key: "name", DisplayName: "Client", SortIdentifier: "Name"},
}, AutoTableStateOptions{Columns: AutoTableColumnOptions{StorageKey: "k"}})
s.SetRows([]any{calcRec{"Ada", "100", "40"}})
if s.LayoutSettled() {
t.Fatal("a persisted table should not start settled")
}
s.RestoreLayout() // nothing to restore here; it just settles
if !s.LayoutSettled() {
t.Fatal("RestoreLayout did not settle the layout")
}
if html := renderNode(s.Render()); !strings.Contains(html, "Client") {
t.Errorf("the table did not reveal after restore:\n%s", html)
}
}

1962
go/webui/autotable_test.go Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -61,11 +61,11 @@ func Badge(p BadgeProps, children ...*vdom.VNode) *vdom.VNode {
if p.Title != "" {
mods = append(mods, vdom.Attr("title", p.Title))
}
return vdom.El("button", kids(mods, children)...)
return vdom.Button(kids(mods, children)...)
}
mods := []vdom.Mod{vdom.Attr("class", badgeClass(p))}
if p.Title != "" {
mods = append(mods, vdom.Attr("title", p.Title))
}
return vdom.El("span", kids(mods, children)...)
return vdom.Span(kids(mods, children)...)
}

View File

@@ -125,7 +125,7 @@ func Button(p ButtonProps, children ...*vdom.VNode) *vdom.VNode {
mods = append(mods, vdom.Text(p.Text))
}
mods = kids(mods, children)
return vdom.El("button", mods...)
return vdom.Button(mods...)
}
func iconSize(small bool) int {
@@ -144,7 +144,7 @@ func ButtonLink(onClick func(), children ...*vdom.VNode) *vdom.VNode {
if onClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
}
return vdom.El("button", kids(mods, children)...)
return vdom.Button(kids(mods, children)...)
}
// ButtonLinkRed is the red variant of ButtonLink.
@@ -156,7 +156,7 @@ func ButtonLinkRed(onClick func(), children ...*vdom.VNode) *vdom.VNode {
if onClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
}
return vdom.El("button", kids(mods, children)...)
return vdom.Button(kids(mods, children)...)
}
// SegmentedButtonOption is one option in a SegmentedButtons group.
@@ -193,10 +193,10 @@ func SegmentedButtons(options []SegmentedButtonOption, value string, onChange fu
if opt.Icon != "" {
btnMods = append(btnMods, Icon(opt.Icon, 12, ""))
}
btnMods = append(btnMods, vdom.El("span", vdom.Text(opt.Label)))
mods = append(mods, vdom.El("button", btnMods...))
btnMods = append(btnMods, vdom.Span(vdom.Text(opt.Label)))
mods = append(mods, vdom.Button(btnMods...))
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}
// BackLink is an inline chevron-left anchor. onDark switches to on-dark colors.
@@ -205,8 +205,7 @@ func BackLink(href, text string, onDark bool) *vdom.VNode {
if onDark {
color = "text-text-on-dark-muted hover:text-text-on-dark"
}
return vdom.El("a",
vdom.Attr("href", href),
return vdom.A(vdom.Attr("href", href),
vdom.Attr("class", cx("inline-flex items-center gap-1 text-sm no-underline", color)),
Icon("chevron-left", 16, ""),
vdom.Text(text),

View File

@@ -113,9 +113,9 @@ func calMonthCells(year int, month time.Month) []calCell {
func calWeekdaysRow(rowCls, cellCls string) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", rowCls)}
for _, d := range calDays {
mods = append(mods, vdom.El("div", vdom.Attr("class", cellCls), vdom.Text(d)))
mods = append(mods, vdom.Div(vdom.Attr("class", cellCls), vdom.Text(d)))
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}
func calDayClassPicker(empty, selected, today bool) string {
@@ -185,15 +185,15 @@ func calPickerDaysGrid(view, sel time.Time, hasSel bool, now time.Time, onSelect
if !cell.empty {
num = strconv.Itoa(cell.date.Day())
}
btnMods = append(btnMods, vdom.El("span", vdom.Attr("class", "leading-none"), vdom.Text(num)))
btnMods = append(btnMods, vdom.Span(vdom.Attr("class", "leading-none"), vdom.Text(num)))
if !cell.empty && renderDay != nil {
if extra := renderDay(calDateKey(cell.date), cell.date); extra != nil {
btnMods = append(btnMods, extra)
}
}
mods = append(mods, vdom.El("button", btnMods...))
mods = append(mods, vdom.Button(btnMods...))
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}
// calMonthDaysGrid renders the month-variant day grid.
@@ -221,15 +221,15 @@ func calMonthDaysGrid(view, sel time.Time, hasSel bool, now time.Time, onSelect
if !cell.empty {
num = strconv.Itoa(cell.date.Day())
}
btnMods = append(btnMods, vdom.El("span", vdom.Attr("class", calDayNumberClassMonth(today)), vdom.Text(num)))
btnMods = append(btnMods, vdom.Span(vdom.Attr("class", calDayNumberClassMonth(today)), vdom.Text(num)))
if !cell.empty && renderDay != nil {
if extra := renderDay(calDateKey(cell.date), cell.date); extra != nil {
btnMods = append(btnMods, extra)
}
}
mods = append(mods, vdom.El("button", btnMods...))
mods = append(mods, vdom.Button(btnMods...))
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}
// CalendarProps configures Calendar. It is a controlled component: the visible
@@ -289,17 +289,15 @@ func Calendar(p CalendarProps) *vdom.VNode {
}
}
header := vdom.El("div", vdom.Attr("class", headerCls),
vdom.El("button",
vdom.Attr("type", "button"),
header := vdom.Div(vdom.Attr("class", headerCls),
vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", calNavBtn),
vdom.On(vdom.EVENT_CLICK, navigate(prev)),
Icon("chevron-left", 16, ""),
),
vdom.El("span", vdom.Attr("class", myCls),
vdom.Span(vdom.Attr("class", myCls),
vdom.Text(calMonths[int(view.Month())-1]+" "+strconv.Itoa(view.Year()))),
vdom.El("button",
vdom.Attr("type", "button"),
vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", calNavBtn),
vdom.On(vdom.EVENT_CLICK, navigate(next)),
Icon("chevron-right", 16, ""),
@@ -313,8 +311,7 @@ func Calendar(p CalendarProps) *vdom.VNode {
daysGrid = calPickerDaysGrid(view, sel, hasSel, now, p.OnSelect, p.RenderDay)
}
return vdom.El("div",
vdom.Attr("class", cx(rootCls, p.Class)),
return vdom.Div(vdom.Attr("class", cx(rootCls, p.Class)),
header,
calWeekdaysRow(weekdaysCls, weekdayCls),
daysGrid,

View File

@@ -19,7 +19,7 @@ const cutCornerCard = "relative isolate p-5 w-full " +
"after:[clip-path:polygon(15px_0,100%_0,100%_calc(100%_-_15px),calc(100%_-_15px)_100%,0_100%,0_15px)]"
func cardDiv(base, class string, children []*vdom.VNode) *vdom.VNode {
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx(base, class))}, children)...)
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx(base, class))}, children)...)
}
// Card is the standard padded, flex-grow card.
@@ -53,21 +53,21 @@ const cardHeaderHR = "text-neutral-200 mt-1 mb-3"
// CardHeader renders a card title followed by a divider.
func CardHeader(class string, children ...*vdom.VNode) *vdom.VNode {
mods := kids([]vdom.Mod{vdom.Attr("class", cx(cardHeader, class))}, children)
mods = append(mods, vdom.El("hr", vdom.Attr("class", cardHeaderHR)))
return vdom.El("div", mods...)
mods = append(mods, vdom.Hr(vdom.Attr("class", cardHeaderHR)))
return vdom.Div(mods...)
}
// CardHeaderTextCenter is CardHeader, centered.
func CardHeaderTextCenter(class string, children ...*vdom.VNode) *vdom.VNode {
mods := kids([]vdom.Mod{vdom.Attr("class", cx(cardHeader, "text-center", class))}, children)
mods = append(mods, vdom.El("hr", vdom.Attr("class", cardHeaderHR)))
return vdom.El("div", mods...)
mods = append(mods, vdom.Hr(vdom.Attr("class", cardHeaderHR)))
return vdom.Div(mods...)
}
// CardSubheader renders a smaller secondary heading.
func CardSubheader(class string, children ...*vdom.VNode) *vdom.VNode {
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-black mb-2", class))}, children)...)
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("text-lg tracking-tight text-black mb-2", class))}, children)...)
}
// CardSpacer is vertical spacing between cards.
func CardSpacer() *vdom.VNode { return vdom.El("div", vdom.Attr("class", "mb-6")) }
func CardSpacer() *vdom.VNode { return vdom.Div(vdom.Attr("class", "mb-6")) }

View File

@@ -192,14 +192,14 @@ func SortableHeader(p SortableHeaderProps) *vdom.VNode {
inner := []vdom.Mod{
vdom.Attr("class", "flex items-center gap-0.5 min-w-0"),
vdom.El("span", vdom.Attr("class", "truncate min-w-0 flex-1"), vdom.Text(p.Label)),
vdom.Span(vdom.Attr("class", "truncate min-w-0 flex-1"), vdom.Text(p.Label)),
}
if isActive {
icon := "caret-up"
if p.Desc {
icon = "caret-down"
}
inner = append(inner, vdom.El("span", vdom.Attr("class", "shrink-0"), Icon(icon, 10, "")))
inner = append(inner, vdom.Span(vdom.Attr("class", "shrink-0"), Icon(icon, 10, "")))
}
mods := []vdom.Mod{vdom.Attr("class", cls)}
@@ -207,8 +207,8 @@ func SortableHeader(p SortableHeaderProps) *vdom.VNode {
key := p.SortKey
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSort(key) }))
}
mods = append(mods, vdom.El("div", inner...))
return vdom.El("th", mods...)
mods = append(mods, vdom.Div(inner...))
return vdom.Th(mods...)
}
func cellGridConflictSets(p CellGridProps) map[string]map[string]bool {
@@ -366,7 +366,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
if sz := cellGridColumnSize(col.Width, col.MinWidth); sz != "" {
cls += " " + sz
}
return vdom.El("th", vdom.Attr("class", cls), vdom.Text(col.Label))
return vdom.Th(vdom.Attr("class", cls), vdom.Text(col.Label))
}
renderCell := func(row map[string]any, col CellGridColumn) *vdom.VNode {
@@ -393,7 +393,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { col.OnClick(row) }))
}
mods = append(mods, col.Render(row))
return vdom.El("td", mods...)
return vdom.Td(mods...)
}
// Read-only cell.
@@ -407,7 +407,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
} else {
mods = append(mods, vdom.Text(cellGridStr(row[col.Key])))
}
return vdom.El("td", mods...)
return vdom.Td(mods...)
}
// Editable cell.
@@ -424,8 +424,7 @@ func CellGrid(p CellGridProps) *vdom.VNode {
if im == "" {
im = "text"
}
input := vdom.El("input",
vdom.Attr("class", inputCls),
input := vdom.Input(vdom.Attr("class", inputCls),
vdom.Attr("type", "text"),
vdom.Attr("inputmode", im),
vdom.Attr("placeholder", col.Placeholder),
@@ -442,20 +441,19 @@ func CellGrid(p CellGridProps) *vdom.VNode {
)
mods := []vdom.Mod{vdom.Attr("class", tdCls), input}
if inList && hasConflict {
mods = append(mods, vdom.El("span",
vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600"),
mods = append(mods, vdom.Span(vdom.Attr("class", "pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600"),
vdom.Attr("title", "Duplicate value"),
Icon("triangle-exclamation", 12, ""),
))
}
return vdom.El("td", mods...)
return vdom.Td(mods...)
}
var headerCells []*vdom.VNode
for _, col := range p.Columns {
headerCells = append(headerCells, renderHeader(col))
}
thead := vdom.El("thead", vdom.El("tr", kids(nil, headerCells)...))
thead := vdom.Thead(vdom.Tr(kids(nil, headerCells)...))
var bodyRows []*vdom.VNode
for _, row := range cellGridSortedRows(p) {
@@ -463,17 +461,15 @@ func CellGrid(p CellGridProps) *vdom.VNode {
for _, col := range p.Columns {
cells = append(cells, renderCell(row, col))
}
bodyRows = append(bodyRows, vdom.El("tr",
kids([]vdom.Mod{vdom.Attr("class", "odd:bg-white even:bg-neutral-100")}, cells)...))
bodyRows = append(bodyRows, vdom.Tr(kids([]vdom.Mod{vdom.Attr("class", "odd:bg-white even:bg-neutral-100")}, cells)...))
}
tbody := vdom.El("tbody", kids(nil, bodyRows)...)
tbody := vdom.Tbody(kids(nil, bodyRows)...)
tableCls := "min-w-full w-max border-collapse text-sm"
if p.Dense {
tableCls = "min-w-full w-max border-collapse text-xs"
}
return vdom.El("div",
vdom.Attr("class", "relative max-w-full overflow-x-auto border border-neutral-300 rounded-default bg-white tabular-nums"),
vdom.El("table", vdom.Attr("class", tableCls), thead, tbody),
return vdom.Div(vdom.Attr("class", "relative max-w-full overflow-x-auto border border-neutral-300 rounded-default bg-white tabular-nums"),
vdom.Table(vdom.Attr("class", tableCls), thead, tbody),
)
}

View File

@@ -37,8 +37,7 @@ type ReactiveChartProps struct {
// ReactiveChart renders the chart container (h-full + user class) wrapping an
// empty <canvas>. Drawing is out of scope — see the file NOTE.
func ReactiveChart(p ReactiveChartProps) *vdom.VNode {
return vdom.El("div",
vdom.Attr("class", cx("h-full", p.Class)),
vdom.El("canvas"),
return vdom.Div(vdom.Attr("class", cx("h-full", p.Class)),
vdom.Canvas(),
)
}

View File

@@ -41,7 +41,7 @@ func crmTabsPanels(p CrmTabGroupProps) []*vdom.VNode {
if i == p.ActiveIndex {
cls = ""
}
out = append(out, vdom.El("div", vdom.Attr("class", cls), item.Content))
out = append(out, vdom.Div(vdom.Attr("class", cls), item.Content))
}
return out
}
@@ -75,16 +75,15 @@ func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
vdom.Text(item.Title),
}
if item.Badge > 0 {
btn = append(btn, vdom.El("span", vdom.Attr("class", crmTabBadge), vdom.Text(strconv.Itoa(item.Badge))))
btn = append(btn, vdom.Span(vdom.Attr("class", crmTabBadge), vdom.Text(strconv.Itoa(item.Badge))))
}
row = append(row, vdom.El("button", btn...))
row = append(row, vdom.Button(btn...))
}
row = append(row, vdom.El("div", vdom.Attr("class", "flex-1 border-b border-neutral-300")))
row = append(row, vdom.Div(vdom.Attr("class", "flex-1 border-b border-neutral-300")))
return vdom.El("div",
vdom.Attr("class", "w-full"),
vdom.El("div", row...),
vdom.El("div", kids(nil, crmTabsPanels(p))...),
return vdom.Div(vdom.Attr("class", "w-full"),
vdom.Div(row...),
vdom.Div(kids(nil, crmTabsPanels(p))...),
)
}
@@ -123,16 +122,14 @@ func CrmSubTabGroup(p CrmTabGroupProps) *vdom.VNode {
vdom.Text(item.Title),
}
if item.Badge > 0 {
btn = append(btn, vdom.El("span", vdom.Attr("class", crmSubTabBadge), vdom.Text(strconv.Itoa(item.Badge))))
btn = append(btn, vdom.Span(vdom.Attr("class", crmSubTabBadge), vdom.Text(strconv.Itoa(item.Badge))))
}
group = append(group, vdom.El("button", btn...))
group = append(group, vdom.Button(btn...))
}
return vdom.El("div",
vdom.Attr("class", "w-full pt-3"),
vdom.El("div",
vdom.Attr("class", crmSubTabWrap),
vdom.El("div", group...),
return vdom.Div(vdom.Attr("class", "w-full pt-3"),
vdom.Div(vdom.Attr("class", crmSubTabWrap),
vdom.Div(group...),
),
vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", "pt-3")}, crmTabsPanels(p))...),
vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "pt-3")}, crmTabsPanels(p))...),
)
}

View File

@@ -1,50 +1,97 @@
// Port of web/kit/DatePicker.tsx. Date math uses the stdlib time package.
// Port of web/kit/DatePicker.tsx. Date math uses the stdlib time package; the
// dropdown is placed by the Floating controller (floating.go) over the host API
// (kjol/wasmruntime), so it portals to <body>, is measured before it is revealed,
// and closes on an outside mousedown or Escape.
//
// This file is a CONSTRUCTOR + Render() pair rather than a bare props->VNode
// function, because it owns live state: a Floating controller, four signals and two
// refs. Those must be created ONCE — building them inside a render function would
// hand every frame a fresh (empty) ref and a fresh controller. Callers keep the
// value:
//
// dp := webui.NewDatePicker(webui.DatePickerProps{OnChange: func(v string) { ... }})
// // ...in the render function:
// dp.Render()
//
// The component is uncontrolled (like the TSX): Props.Value seeds it, OnChange
// reports every commit, and SetValue pushes a new value in from outside.
package webui
import (
"fmt"
"strconv"
"strings"
"time"
"kjol/vdom"
"kjol/wasmruntime"
)
// -- Tailwind class strings, verbatim from DatePicker.tsx (cmd/twcss scans these) --
const datePickerWrap = "relative w-full min-w-0"
const datePickerField = "relative w-full min-w-0 cursor-pointer [&_.ui-form]:m-0 [&_input]:cursor-text"
const datePickerDropdown = "bg-white border border-neutral-200 rounded-default shadow-[0_4px_12px_rgba(0,0,0,0.15)] p-1 min-w-[16rem]"
const datePickerIconBtn = "absolute inset-y-0 right-0 z-[1] flex items-center justify-center bg-transparent border-0 px-2 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
const datePickerClearBtn = "absolute inset-y-0 right-8 z-[1] flex items-center justify-center bg-transparent border-0 px-1.5 cursor-pointer text-text-muted leading-none hover:text-text-body pointer-events-auto"
// NOTE: the TSX renders the dropdown into a <Portal> with fixed coordinates from
// getBoundingClientRect (tracked each frame). Portals, refs and element
// measurement have no equivalent here, so the dropdown is rendered inline and
// positioned with static Tailwind (absolute, below the field).
// datePickerDropdownPos positions the DATE-OF-BIRTH dropdown only. That one is
// inline in the TSX too (DatePicker.tsx:535-539: a plain <div>, no Portal, no
// measurement), so it stays inline here — anchored with static Tailwind instead of
// being portaled and measured. DatePicker's dropdown does NOT use this; it goes
// through Floating.
const datePickerDropdownPos = "absolute left-0 top-full mt-1 z-[200]"
// datePickerInputBase mirrors Forms.tsx INPUT_BASE (light, no error/success).
const datePickerInputBase = "bg-white block w-full border rounded-default shadow-xs text-sm focus:outline-2 focus:outline-offset-1 disabled:bg-neutral-100 disabled:cursor-not-allowed"
// datePickerBoxStyle is the declared inline style of the portaled dropdown box.
// It must be IDENTICAL on every render: the reconciler only writes the style
// attribute when the declared string changes, which is what keeps it from
// clobbering the min-width/max-width that applyWidth writes imperatively (and the
// top/left/max-height that Floating writes on its own panel).
const datePickerBoxStyle = "width:max-content"
// datepickerInputCls reproduces Forms.tsx _inputCls for the light, no-state case.
func datepickerInputCls(small bool, extra string) string {
h, pad := "h-[38px]", "p-2"
if small {
h, pad = "h-[30px]", "p-1"
}
return cx(datePickerInputBase, h, pad, "border-neutral-300 focus:outline-sky-500", extra)
}
// Dropdown sizing, from DatePicker.tsx:207-211 (min-width 16rem, max-width 400,
// 8px viewport gutter).
const (
datePickerMinWidth = 256.0
datePickerMaxWidth = 400.0
datePickerGutter = 8.0
)
// datepickerParseInput parses typed/pasted text into a "YYYY-MM-DD" key, or "".
// -- parsing / formatting -----------------------------------------------------
// datePickerLayouts are tried in order against the (whitespace-normalized) input.
// The TSX fell back to `new Date(anyString)`, which Go has no equivalent of, so the
// lenience is spelled out here instead.
//
// NOTE: the TSX first tries YYYY-MM-DD, then falls back to the very lenient
// `new Date(text)`. Go has no equivalent, so a fixed set of common layouts is
// tried instead.
func datepickerParseInput(text string) string {
s := strings.TrimSpace(text)
// Numeric fields use the NON-padded verbs ("1", "2") on purpose: time.Parse accepts
// both "7" and "07" for those, so one layout covers "7/4/2026" and "07/04/2026".
// Two-digit years need their own entries ("06"); Go maps 69-99 to 19xx and 00-68 to
// 20xx. Month names are matched case-insensitively by time.Parse, so "jan 2 2026"
// works. Ambiguous all-numeric forms are read month-first (US), which is what the
// JS Date constructor the TSX relied on does for slash-separated dates.
var datePickerLayouts = []string{
"2006-01-02", // ISO — what this component emits, so it is the fast path
"2006-1-2",
"2006/1/2",
"20060102",
"1/2/2006", "1-2-2006", "1.2.2006",
"1/2/06", "1-2-06", "1.2.06",
"January 2, 2006", "Jan 2, 2006",
"January 2 2006", "Jan 2 2006",
"2 January 2006", "2 Jan 2006",
"2006-01-02T15:04:05", // a pasted timestamp
"2006-01-02 15:04:05",
time.RFC3339,
}
// datePickerParse parses typed or pasted text into a "YYYY-MM-DD" key, or "" if
// nothing in datePickerLayouts matches. Invalid calendar dates (Feb 30) fail to
// parse, so they clear the field rather than silently rolling over.
func datePickerParse(text string) string {
s := strings.Join(strings.Fields(text), " ") // trim + collapse inner whitespace
if s == "" {
return ""
}
for _, layout := range []string{"2006-01-02", "1/2/2006", "01/02/2006", "2006/01/02", "January 2, 2006", "Jan 2, 2006"} {
for _, layout := range datePickerLayouts {
if t, err := time.Parse(layout, s); err == nil {
return t.Format("2006-01-02")
}
@@ -52,142 +99,513 @@ func datepickerParseInput(text string) string {
return ""
}
// datepickerFormatDisplay renders a "YYYY-MM-DD" key for display.
// datePickerFormat renders a "YYYY-MM-DD" key for display.
//
// NOTE: the TSX uses Date.toLocaleDateString() (locale-dependent). Go has no
// locale formatter, so this approximates the common en-US "M/D/YYYY" form.
func datepickerFormatDisplay(iso string) string {
// NOTE: the TSX uses Date.toLocaleDateString() (locale-dependent). Go has no locale
// formatter, so this produces the common en-US "M/D/YYYY" form — which is also the
// first thing datePickerParse accepts, so a value survives a display -> edit ->
// commit round trip unchanged.
func datePickerFormat(iso string) string {
t, ok := calParseKey(iso)
if !ok {
return ""
}
return fmt.Sprintf("%d/%d/%d", int(t.Month()), t.Day(), t.Year())
return strconv.Itoa(int(t.Month())) + "/" + strconv.Itoa(t.Day()) + "/" + strconv.Itoa(t.Year())
}
// datepickerInput renders the FormInput approximation: a .ui-form wrapper around
// a styled <input>. onChange commits the parsed value on the native change event.
// -- shared state + field rendering -------------------------------------------
// DatePickerProps configures NewDatePicker and NewDateOfBirthPicker.
type DatePickerProps struct {
Value string // initial selection, "YYYY-MM-DD" ("" = none)
OnChange func(value string) // fired on every commit: a picked day, a typed date, or "" when cleared
Placeholder string // input placeholder
Small bool // compact input height
Clearable bool // show a clear (x) button when a value is set (DatePicker only)
}
// datePickerCore is the half the two pickers share: the value/draft/editing state
// machine, the visible month, and the text field. Both pickers embed it.
//
// NOTE: the TSX FormInput tracks an editing/draft state machine across
// focus/input/blur. Without persistent local signals that collapses to a single
// onchange commit; the input shows the formatted external value otherwise.
func datepickerInput(value, placeholder string, small bool, extra string, onChange func(string)) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("type", "text"),
vdom.Attr("class", datepickerInputCls(small, extra)),
vdom.Attr("placeholder", placeholder),
vdom.Prop("value", value),
}
if onChange != nil {
mods = append(mods, vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) {
onChange(datepickerParseInput(e.Value()))
}))
}
return vdom.El("div", vdom.Attr("class", "ui-form"), vdom.El("input", mods...))
// The state machine (restored from the TSX; the first Go port collapsed it to a
// single onchange commit):
//
// focus -> editing = true, draft = the formatted value
// input -> draft = what was typed
// blur -> commit(draft), editing = false
// outside click -> commit(draft) as well, then close
//
// While editing, the input shows the raw draft — so typing is not fought by a
// reformat on every keystroke.
type datePickerCore struct {
props DatePickerProps
placeholder string
value *vdom.Signal[string] // the committed selection, "YYYY-MM-DD"
draft *vdom.Signal[string] // the raw text in the input while editing
editing *vdom.Signal[bool] // is the input focused / being typed into
view *vdom.Signal[time.Time] // the calendar's visible month; zero = derive from the selection
// fieldRef is on the .ui-form wrapper, which is a block box the full width of
// the field. The dropdown's min-width is the field's width, and Floating owns
// (and does not expose) the trigger ref, so this is what we measure.
fieldRef *vdom.Ref
}
func datepickerIconButton(onClick func()) *vdom.VNode {
return vdom.El("button",
vdom.Attr("type", "button"),
func newDatePickerCore(p DatePickerProps, placeholder string) datePickerCore {
return datePickerCore{
props: p,
placeholder: pick(p.Placeholder, placeholder),
value: vdom.NewSignal(p.Value),
draft: vdom.NewSignal(datePickerFormat(p.Value)),
editing: vdom.NewSignal(false),
view: vdom.NewSignal(time.Time{}),
fieldRef: vdom.NewRef(),
}
}
// Value is the current selection as "YYYY-MM-DD" ("" when empty).
func (c *datePickerCore) Value() string { return c.value.Get() }
// SetValue pushes a selection in from outside. It does NOT fire OnChange — the
// caller already knows.
func (c *datePickerCore) SetValue(key string) { c.set(key, false) }
// display is the formatted committed value; inputValue is what the <input> shows.
func (c *datePickerCore) display() string { return datePickerFormat(c.value.Get()) }
func (c *datePickerCore) inputValue() string {
if c.editing.Get() {
return c.draft.Get()
}
return c.display()
}
// set makes key the selection and ends editing. The visible month follows the
// selection (the TSX did this with a createEffect on `selected`).
func (c *datePickerCore) set(key string, notify bool) {
dpSet(c.editing, false)
if c.value.Get() != key {
dpSet(c.value, key)
dpSet(c.view, time.Time{}) // re-derive from the new selection
}
dpSet(c.draft, datePickerFormat(key))
if notify && c.props.OnChange != nil {
c.props.OnChange(key)
}
}
// commit parses raw text and makes it the selection.
//
// OnChange fires only if the value actually CHANGED. Focus seeds the draft from the
// displayed value, so a plain focus/blur (or a click that moves focus into the
// dropdown) commits a draft identical to what is already selected — the TSX fired
// onchange on every one of those, and on an empty field it fired onchange("") just
// for tabbing through.
func (c *datePickerCore) commit(raw string) {
key := datePickerParse(raw)
c.set(key, key != c.value.Get())
}
// commitDraft commits whatever is in the input, if it is being edited. Called from
// blur and from the outside-click close.
func (c *datePickerCore) commitDraft() {
if c.editing.Get() {
c.commit(c.draft.Get())
}
}
func (c *datePickerCore) navigate(month time.Time) { dpSet(c.view, month) }
// input renders the field's <input>, wrapped in .ui-form — the same markup
// FormInput produces (its classes come from the same formInputCls), reproduced here
// because the datepicker needs two things FormInputProps cannot express: a ref on
// the wrapper, and a keydown handler that can see the key.
//
// onOpen runs when the input is clicked (open, never toggle); onEnter runs after
// Enter has committed.
func (c *datePickerCore) input(extra string, onOpen, onEnter func()) *vdom.VNode {
inputMods := []vdom.Mod{
vdom.Attr("type", "text"),
vdom.Attr("class", formInputCls(c.props.Small, "", "", extra, false)),
vdom.Attr("placeholder", c.placeholder),
vdom.Prop("value", c.inputValue()),
vdom.On(vdom.EVENT_FOCUS, func() {
dpSet(c.draft, c.display())
dpSet(c.editing, true)
}),
vdom.OnEvent(vdom.EVENT_INPUT, func(e vdom.Event) { dpSet(c.draft, e.Value()) }),
vdom.On(vdom.EVENT_BLUR, c.commitDraft),
// The field wrapping this input is the Floating trigger, and a trigger's click
// TOGGLES. Clicking the input must only ever OPEN (the TSX's openCalendar), or
// placing the caret in a field with the calendar already down would close it.
// Hence stopPropagation — exactly what the TSX did, for the same reason.
vdom.OnEvent(vdom.EVENT_CLICK, func(e vdom.Event) {
e.StopPropagation()
if onOpen != nil {
onOpen()
}
}),
vdom.OnEvent(vdom.EVENT_KEYDOWN, func(e vdom.Event) {
switch e.Key() {
case vdom.KEY_ESCAPE:
// Let it bubble: Floating's document listener closes the topmost panel.
case vdom.KEY_ENTER:
// Floating.Trigger turns Enter into a toggle *and* preventDefaults it on a
// non-button trigger, which would swallow a form submit. Keep it off the
// trigger and commit instead.
e.StopPropagation()
c.commitDraft()
if onEnter != nil {
onEnter()
}
default:
// Every other key — Space above all, which the trigger would otherwise eat
// to toggle the panel — has to reach the input untouched.
e.StopPropagation()
}
}),
}
return vdom.Div(vdom.Attr("class", "ui-form"),
vdom.WithRef(c.fieldRef),
vdom.Input(inputMods...),
)
}
// iconButton / clearButton sit inside the field, i.e. inside the Floating trigger.
// Both stop the click from reaching it, so the trigger's toggle does not undo what
// they just did (the TSX stopped propagation here too).
func (c *datePickerCore) iconButton(onClick func()) *vdom.VNode {
return vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", datePickerIconBtn),
vdom.Attr("aria-label", "Open calendar"),
vdom.On(vdom.EVENT_CLICK, onClick),
vdom.OnEvent(vdom.EVENT_CLICK, func(e vdom.Event) {
e.StopPropagation()
onClick()
}),
Icon("calendar", 16, "block leading-none"),
)
}
// DatePickerProps configures DatePicker and DateOfBirthPicker. It is controlled:
// Value is the "YYYY-MM-DD" selection, Open is the dropdown state, ViewMonth is
// the calendar's visible month — each paired with a callback.
//
// NOTE: the TSX owns open/editing/localValue/dropdownPos in internal signals and
// closes the dropdown via a document click listener. Outside-click handling,
// stopPropagation and computed positioning aren't representable in the neutral
// runtime, so open/selection/month are lifted to props + callbacks.
type DatePickerProps struct {
Value string // selected date "YYYY-MM-DD"
OnChange func(value string) // new selection (or "" when cleared)
Placeholder string // input placeholder
Small bool // compact input height
Clearable bool // show a clear (x) button when a value is set
Open bool // dropdown open state (controlled)
OnToggle func(open bool) // request to open/close the dropdown
ViewMonth time.Time // visible month of the dropdown calendar
OnNavigate func(month time.Time) // prev/next month requested in the dropdown
func (c *datePickerCore) clearButton(onClick func()) *vdom.VNode {
return vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", datePickerClearBtn),
vdom.Attr("aria-label", "Clear date"),
vdom.OnEvent(vdom.EVENT_CLICK, func(e vdom.Event) {
e.StopPropagation()
onClick()
}),
Icon("xmark", 14, "block leading-none"),
)
}
// DatePicker renders a text field with a calendar dropdown (picker layout).
func DatePicker(p DatePickerProps) *vdom.VNode {
hasValue := p.Value != ""
// dpSet writes a signal only when the value actually changes. Every Signal.Set
// schedules a full re-render, so the no-op writes this component would otherwise do
// on focus, blur and every close are worth skipping.
func dpSet[T comparable](s *vdom.Signal[T], v T) {
if s.Get() != v {
s.Set(v)
}
}
// -- DatePicker ----------------------------------------------------------------
// DatePicker is a text field with a portaled calendar dropdown.
//
// The dropdown is a Floating panel: portaled to <body> (so no scrolling or
// overflow-hidden ancestor can clip it — the first Go port used a static
// `absolute top-full`, which any such ancestor cut off), measured before it is
// revealed, repositioned on scroll/resize/content-resize, and closed by an outside
// mousedown or Escape.
//
// Three deliberate departures from the TSX:
//
// 1. FLIP IS ON. The TSX pinned the dropdown below the field and let it run off the
// bottom of the page. Flipping to `top-start` when there is no room below keeps
// the calendar reachable for a field near the bottom of a long form, and
// ConstrainToViewport caps the panel to the room that is actually there so it
// scrolls instead of overflowing. This is an improvement, not a port bug.
// 2. NO PER-FRAME TRACKING LOOP. The TSX re-measured the field on every animation
// frame for as long as the dropdown was open (DatePicker.tsx:213-224) — the most
// expensive thing in the kit. Floating tracks with scroll(capture) + resize +
// ResizeObserver instead: it fires only when something that feeds the math has
// actually moved, and (unlike the rAF loop) it also catches the panel's own
// content changing size.
// 3. The panel sits on the kit's floating layer (z-[110], set by Floating) rather
// than the TSX's ad-hoc z-index 200, so it stacks consistently with menus,
// popovers and tooltips.
type DatePicker struct {
datePickerCore
float *Floating
box *vdom.Ref // the bordered dropdown box inside the Floating panel
unsub wasmruntime.Unsub // window resize -> re-measure the field width
}
// NewDatePicker builds a DatePicker. Call it ONCE, outside your render function
// (see the package doc on floating.go): it creates the Floating controller, the
// signals and the refs, all of which have to survive across renders.
func NewDatePicker(p DatePickerProps) *DatePicker {
d := &DatePicker{
datePickerCore: newDatePickerCore(p, "Select date"),
box: vdom.NewRef(),
}
d.float = NewFloating(FloatingOptions{
Placement: PlacementBottomStart, // DatePicker.tsx: top = field.bottom + 4, left = field.left
Offset: 4,
ConstrainToViewport: true, // scroll, don't overflow, when the calendar is taller than the room below
OnOpenChange: d.onOpenChange,
})
return d
}
// Show / Hide / Toggle drive the dropdown from outside.
func (d *DatePicker) Show() { d.float.Show() }
func (d *DatePicker) Hide() { d.float.Hide() }
func (d *DatePicker) Toggle() { d.float.Toggle() }
func (d *DatePicker) IsOpen() bool { return d.float.IsOpen() }
// Dispose closes the dropdown and drops every listener. Call it if the page owning
// this picker goes away while the dropdown might still be open.
func (d *DatePicker) Dispose() {
d.unsubResize()
d.float.Dispose()
}
func (d *DatePicker) onOpenChange(open bool) {
if open {
// Queued BEFORE Floating queues its own measure pass — Show() calls
// OnOpenChange first, and AfterRender callbacks run in the order they were
// added — so the panel is already at its final width when Floating measures it.
wasmruntime.AfterRender(d.mounted)
return
}
// Closing. An outside mousedown gets here through Floating's own listener, and
// the TSX committed the in-flight draft on exactly that path; so does this. (The
// blur that follows the click finds editing == false and does nothing, instead of
// committing a second time the way the TSX did.)
d.commitDraft()
d.unsubResize()
dpSet(d.view, time.Time{}) // next open starts on the selection's month, as in the TSX
}
// mounted runs once the panel is in the DOM.
func (d *DatePicker) mounted() {
d.applyWidth()
// Floating already repositions on resize; this re-derives the WIDTH, which is a
// function of the field's own width and so can change when the window does.
d.unsub = wasmruntime.OnWindow(vdom.EVENT_RESIZE, false, func(vdom.Event) {
d.applyWidth()
d.float.Reposition()
})
}
// applyWidth writes the dropdown's width constraints (DatePicker.tsx:207-211):
// min-width is the field's own width but never under 16rem, width is max-content,
// max-width is 400px. Written with SetStyle rather than through a signal — a signal
// write re-renders the whole tree.
//
// The TSX also clamped max-width to `innerWidth - left - 8`, its only defence
// against a field near the right edge pushing the dropdown off screen. Floating's
// shift does that properly (it slides the panel back inside the viewport instead of
// squeezing it), so what is kept here is the 400px cap and the 8px gutter.
func (d *DatePicker) applyWidth() {
if !d.box.Mounted() {
return
}
minW := datePickerMinWidth
if f := wasmruntime.Measure(d.fieldRef); f.Width > minW {
minW = f.Width
}
maxW := datePickerMaxWidth
if vp := wasmruntime.Viewport(); vp.Width > 0 {
if room := vp.Width - 2*datePickerGutter; room < maxW {
maxW = room
}
}
if maxW < minW {
minW = maxW // a viewport narrower than the field: the cap wins
}
wasmruntime.SetStyle(d.box, "min-width", px(minW))
wasmruntime.SetStyle(d.box, "max-width", px(maxW))
}
func (d *DatePicker) unsubResize() {
if d.unsub != nil {
d.unsub()
d.unsub = nil
}
}
// Render builds the current frame. Safe to call on every render — it creates no
// state.
func (d *DatePicker) Render() *vdom.VNode {
hasValue := d.value.Get() != ""
extra := "w-full pr-9"
if p.Clearable && hasValue {
if d.props.Clearable && hasValue {
extra = "w-full pr-14"
}
fieldMods := []vdom.Mod{
vdom.Attr("class", datePickerField),
vdom.On(vdom.EVENT_CLICK, func() {
if p.OnToggle != nil {
p.OnToggle(true)
}
}),
datepickerInput(datepickerFormatDisplay(p.Value), pick(p.Placeholder, "Select date"), p.Small, extra, p.OnChange),
}
if p.Clearable && hasValue {
fieldMods = append(fieldMods, vdom.El("button",
vdom.Attr("type", "button"),
vdom.Attr("class", datePickerClearBtn),
vdom.Attr("aria-label", "Clear date"),
vdom.On(vdom.EVENT_CLICK, func() {
if p.OnChange != nil {
p.OnChange("")
}
if p.OnToggle != nil {
p.OnToggle(false)
}
}),
Icon("xmark", 14, "block leading-none"),
))
}
fieldMods = append(fieldMods, datepickerIconButton(func() {
if p.OnToggle != nil {
p.OnToggle(!p.Open)
}
children := []*vdom.VNode{d.input(extra, d.float.Show, d.float.Hide)}
if d.props.Clearable && hasValue {
children = append(children, d.clearButton(func() {
d.set("", true)
d.float.Hide()
}))
}
children = append(children, d.iconButton(d.float.Toggle))
wrapMods := []vdom.Mod{
vdom.Attr("class", datePickerWrap),
vdom.El("div", fieldMods...),
// The field IS the Floating trigger: it carries the ref Floating measures and
// the ref its outside-click test uses, so a click anywhere inside the field
// (input, buttons) is never "outside".
field := d.float.Trigger(FloatingTriggerProps{
Tag: "div",
Class: datePickerField,
AriaHasPopup: "dialog",
}, children...)
return vdom.Div(vdom.Attr("class", datePickerWrap), field, d.panel())
}
if p.Open {
// panel renders the portaled dropdown. Closed, Floating.Panel returns an empty
// portal, which keeps this child's slot in the parent's list (the reconciler diffs
// children by index).
//
// The bordered box is a child of the panel rather than the panel itself, because
// Floating owns the panel's ref and its style (top/left/max-height): this box gets
// our own ref, so applyWidth can size it.
func (d *DatePicker) panel() *vdom.VNode {
if !d.float.IsOpen() {
return d.float.Panel(FloatingPanelProps{Role: "dialog"})
}
cal := Calendar(CalendarProps{
Selected: p.Value,
ViewMonth: p.ViewMonth,
Selected: d.value.Get(),
ViewMonth: d.view.Get(),
Variant: CalendarVariantPicker,
OnSelect: func(key string) {
if p.OnChange != nil {
p.OnChange(key)
}
if p.OnToggle != nil {
p.OnToggle(false)
}
},
OnNavigate: p.OnNavigate,
OnSelect: d.selectDay,
OnNavigate: d.navigate,
})
wrapMods = append(wrapMods, vdom.El("div",
vdom.Attr("class", cx(datePickerDropdown, datePickerDropdownPos)),
box := vdom.Div(vdom.WithRef(d.box),
vdom.Attr("class", datePickerDropdown),
vdom.Attr("style", datePickerBoxStyle),
cal,
)
return d.float.Panel(FloatingPanelProps{Role: "dialog"}, box)
}
func (d *DatePicker) selectDay(key string) {
d.set(key, true)
d.float.Hide()
}
// -- DateOfBirthPicker ---------------------------------------------------------
// DateOfBirthPicker is a DatePicker whose calendar swaps the month label for
// month/year <select>s, for picking far-past dates.
//
// NOTE: its dropdown is INLINE — not portaled, not measured. That is faithful to the
// TSX (DatePicker.tsx:535-539 renders a plain <div>, with no Portal and no
// getBoundingClientRect), so it is left alone rather than "upgraded" to a Floating
// panel. What it does get back is the outside-click close, hand-rolled here (a
// document mousedown against the wrapper) because there is no Floating to inherit it
// from; that mirrors the TSX's own document listener, on mousedown rather than click
// so it fires before focus moves.
type DateOfBirthPicker struct {
datePickerCore
open *vdom.Signal[bool]
wrap *vdom.Ref
unsub wasmruntime.Unsub // document mousedown, installed only while open
}
// NewDateOfBirthPicker builds a DateOfBirthPicker. Like NewDatePicker, call it once,
// outside your render function. Props.Clearable is ignored (the TSX has no clear
// button on this one).
func NewDateOfBirthPicker(p DatePickerProps) *DateOfBirthPicker {
return &DateOfBirthPicker{
datePickerCore: newDatePickerCore(p, "Select date of birth"),
open: vdom.NewSignal(false),
wrap: vdom.NewRef(),
}
}
func (d *DateOfBirthPicker) IsOpen() bool { return d.open.Get() }
func (d *DateOfBirthPicker) Show() {
if d.open.Get() {
return
}
d.open.Set(true)
d.unsub = wasmruntime.OnDocument(vdom.EVENT_MOUSEDOWN, false, func(e vdom.Event) {
if wasmruntime.Contains(d.wrap, e.Target()) {
return
}
d.commitDraft() // the TSX committed the draft on an outside click too
d.Hide()
})
}
func (d *DateOfBirthPicker) Hide() {
if !d.open.Get() {
return
}
d.open.Set(false)
if d.unsub != nil {
d.unsub()
d.unsub = nil
}
dpSet(d.view, time.Time{})
}
func (d *DateOfBirthPicker) Toggle() {
if d.open.Get() {
d.Hide()
} else {
d.Show()
}
}
// Dispose closes the dropdown and drops its listener.
func (d *DateOfBirthPicker) Dispose() { d.Hide() }
func (d *DateOfBirthPicker) Render() *vdom.VNode {
field := vdom.Div(vdom.Attr("class", datePickerField),
// No Floating trigger here, so this click opens rather than toggles — the
// TSX's openCalendar. The input stops its own click anyway; this catches the
// rest of the field.
vdom.On(vdom.EVENT_CLICK, d.Show),
d.input("w-full pr-9", d.Show, d.Hide),
d.iconButton(d.Toggle),
)
mods := []vdom.Mod{
vdom.Attr("class", datePickerWrap),
vdom.WithRef(d.wrap), // the outside-click test measures against this
field,
}
if d.open.Get() {
mods = append(mods, vdom.Div(vdom.Attr("class", cx(datePickerDropdown, datePickerDropdownPos)),
datePickerDOBCalendar(d.value.Get(), d.view.Get(), d.selectDay, d.navigate),
))
}
return vdom.El("div", wrapMods...)
return vdom.Div(mods...)
}
// datepickerDOBCalendar renders the date-of-birth calendar: the picker grid with
func (d *DateOfBirthPicker) selectDay(key string) {
d.set(key, true)
d.Hide()
}
// datePickerDOBCalendar renders the date-of-birth calendar: the picker grid with
// month/year <select> dropdowns in place of the static month label. Navigation
// (prev/next and both selects) is reported through onNavigate as a first-of-month.
func datepickerDOBCalendar(selKey string, view time.Time, onSelect func(string), onNavigate func(time.Time)) *vdom.VNode {
func datePickerDOBCalendar(selKey string, view time.Time, onSelect func(string), onNavigate func(time.Time)) *vdom.VNode {
now := time.Now()
sel, hasSel := calParseKey(selKey)
@@ -218,7 +636,7 @@ func datepickerDOBCalendar(selKey string, view time.Time, onSelect func(string),
}),
}
for i, mn := range calMonthsShort {
monthMods = append(monthMods, vdom.El("option", vdom.Attr("value", strconv.Itoa(i)), vdom.Text(mn)))
monthMods = append(monthMods, vdom.Option(vdom.Attr("value", strconv.Itoa(i)), vdom.Text(mn)))
}
// Year select: current year down to current-119 (120 years), like the TSX.
@@ -233,22 +651,20 @@ func datepickerDOBCalendar(selKey string, view time.Time, onSelect func(string),
}
for y := now.Year(); y > now.Year()-120; y-- {
ys := strconv.Itoa(y)
yearMods = append(yearMods, vdom.El("option", vdom.Attr("value", ys), vdom.Text(ys)))
yearMods = append(yearMods, vdom.Option(vdom.Attr("value", ys), vdom.Text(ys)))
}
header := vdom.El("div", vdom.Attr("class", calHeaderPicker),
vdom.El("button",
vdom.Attr("type", "button"),
header := vdom.Div(vdom.Attr("class", calHeaderPicker),
vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", calNavBtn),
vdom.On(vdom.EVENT_CLICK, func() {
navTo(time.Date(year, month-1, 1, 0, 0, 0, 0, time.UTC))
}),
Icon("chevron-left", 16, ""),
),
vdom.El("select", monthMods...),
vdom.El("select", yearMods...),
vdom.El("button",
vdom.Attr("type", "button"),
vdom.Select(monthMods...),
vdom.Select(yearMods...),
vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", calNavBtn),
vdom.On(vdom.EVENT_CLICK, func() {
navTo(time.Date(year, month+1, 1, 0, 0, 0, 0, time.UTC))
@@ -257,51 +673,9 @@ func datepickerDOBCalendar(selKey string, view time.Time, onSelect func(string),
),
)
return vdom.El("div",
vdom.Attr("class", calPickerRoot),
return vdom.Div(vdom.Attr("class", calPickerRoot),
header,
calWeekdaysRow(calWeekdaysPicker, calWeekdayPicker),
calPickerDaysGrid(view, sel, hasSel, now, onSelect, nil),
)
}
// DateOfBirthPicker is DatePicker with a month/year select calendar, suited to
// picking far-past dates. It reuses DatePickerProps (Clearable is unused).
func DateOfBirthPicker(p DatePickerProps) *vdom.VNode {
fieldMods := []vdom.Mod{
vdom.Attr("class", datePickerField),
vdom.On(vdom.EVENT_CLICK, func() {
if p.OnToggle != nil {
p.OnToggle(true)
}
}),
datepickerInput(datepickerFormatDisplay(p.Value), pick(p.Placeholder, "Select date of birth"), p.Small, "w-full pr-9", p.OnChange),
datepickerIconButton(func() {
if p.OnToggle != nil {
p.OnToggle(!p.Open)
}
}),
}
wrapMods := []vdom.Mod{
vdom.Attr("class", datePickerWrap),
vdom.El("div", fieldMods...),
}
if p.Open {
cal := datepickerDOBCalendar(p.Value, p.ViewMonth, func(key string) {
if p.OnChange != nil {
p.OnChange(key)
}
if p.OnToggle != nil {
p.OnToggle(false)
}
}, p.OnNavigate)
wrapMods = append(wrapMods, vdom.El("div",
vdom.Attr("class", cx(datePickerDropdown, datePickerDropdownPos)),
cal,
))
}
return vdom.El("div", wrapMods...)
}

View File

@@ -43,5 +43,5 @@ func EnvBadge(env string) *vdom.VNode {
label, tone = strings.ToUpper(env), "bg-neutral-700 text-white"
}
return vdom.El("span", vdom.Attr("class", cx(envBadgeBase, tone)), vdom.Text(label))
return vdom.Span(vdom.Attr("class", cx(envBadgeBase, tone)), vdom.Text(label))
}

View File

@@ -1,189 +1,535 @@
package webui
import "kjol/vdom"
import (
"strconv"
// Port of web/kit/Floating.tsx.
//
// The TSX is a floating-ui-style popover kit built on Solid context, refs,
// getBoundingClientRect, requestAnimationFrame, window scroll/resize listeners,
// document-level mousedown/keydown handlers, a Portal to document.body, and a
// global single-open FloatingManager. None of that has an equivalent in the
// neutral vdom runtime, so this port keeps the API shape (Root / Trigger /
// Content) plus the Tailwind, and models open state as a plain value + callback.
//
// NOTE: All computed positioning (calculatePosition, flip, shift, offset px,
// getBoundingClientRect, the position() signal, window scroll/resize reflow) is
// dropped. FloatingContent is approximated with a statically-positioned
// `absolute` element anchored to FloatingRoot's `relative` container, placed via
// Tailwind utilities chosen from the Placement value. The original rendered the
// content through a Portal with `position: fixed`; here it stays in-flow.
// NOTE: The global single-open FloatingManager, the open-order stack
// (openFloatings), outside-click dismissal, Escape-to-close, and hover-open /
// hover-close timers are dropped — callers own open state and decide when to
// toggle it. useFloatingContext / FloatingContextValue and the useFloatingHover
// hook are dropped (no Solid context; nothing to share through).
// Placement values (subset of CSS anchor placements) understood by
// FloatingContent's static positioning approximation.
const (
PlacementTop = "top"
PlacementTopStart = "top-start"
PlacementTopEnd = "top-end"
PlacementBottom = "bottom"
PlacementBottomStart = "bottom-start"
PlacementBottomEnd = "bottom-end"
PlacementLeft = "left"
PlacementLeftStart = "left-start"
PlacementLeftEnd = "left-end"
PlacementRight = "right"
PlacementRightStart = "right-start"
PlacementRightEnd = "right-end"
"kjol/vdom"
"kjol/wasmruntime"
)
// PositionOptions mirrors the TSX PositionOptions. Retained for API parity; in
// this port only Placement influences rendering (see the file-level NOTE) — the
// numeric/flip/shift fields are not consumed because there is no measurement.
type PositionOptions struct {
Placement string
Offset int
Flip bool
Shift bool
ShiftPadding int
// Floating is the live controller for one floating panel — the Go answer to the
// TSX kit's Solid context (which Go has no equivalent of). Tooltips, popovers,
// menus, submenus and the date picker's dropdown are all built on it.
//
// The dance it performs, and why each step exists:
//
// 1. Show() flips a signal, which schedules a render. The panel is rendered
// PORTALED to document.body (so an ancestor's overflow:hidden or transform
// cannot clip it or re-root its `position: fixed`) and laid out but INVISIBLE
// (`visibility: hidden` — which still takes up layout, unlike `display: none`).
// 2. AfterRender fires once the DOM exists. Only now can the panel be measured:
// you cannot know where to put a panel until you know how big it is.
// 3. ComputePosition does the math; the result is written with SetStyle —
// imperatively, straight to the DOM node. NOT through a signal: a signal write
// re-renders the entire app, and this runs on every scroll frame.
// 4. The panel is revealed. It never paints at 0,0.
//
// Create one per floating element, alongside your signals — NOT inside a render
// function, which would rebuild it (and its refs) every frame:
//
// menu := webui.NewFloating(webui.FloatingOptions{Placement: webui.PlacementBottomEnd})
// return func() *vdom.VNode {
// return Div(
// menu.Trigger(webui.FloatingTriggerProps{}, Text("Actions")),
// menu.Panel(webui.FloatingPanelProps{}, items...),
// )
// }
type Floating struct {
id string
opts FloatingOptions
open *vdom.Signal[bool]
resolved *vdom.Signal[string] // placement after flipping; the arrow's side depends on it
triggerRef *vdom.Ref
panelRef *vdom.Ref
arrowRef *vdom.Ref
unsubs []wasmruntime.Unsub
hoverTimer int
}
// floatingPlacementClass maps a Placement to Tailwind utilities that position an
// `absolute` child relative to its `relative` FloatingRoot container. The ~4px
// default offset is approximated with the mt-1/mb-1/ml-1/mr-1 gap classes.
func floatingPlacementClass(placement string) string {
switch placement {
case PlacementTop:
return "bottom-full left-1/2 -translate-x-1/2 mb-1"
case PlacementTopStart:
return "bottom-full left-0 mb-1"
case PlacementTopEnd:
return "bottom-full right-0 mb-1"
case PlacementBottom:
return "top-full left-1/2 -translate-x-1/2 mt-1"
case PlacementBottomEnd:
return "top-full right-0 mt-1"
case PlacementLeft:
return "right-full top-1/2 -translate-y-1/2 mr-1"
case PlacementLeftStart:
return "right-full top-0 mr-1"
case PlacementLeftEnd:
return "right-full bottom-0 mr-1"
case PlacementRight:
return "left-full top-1/2 -translate-y-1/2 ml-1"
case PlacementRightStart:
return "left-full top-0 ml-1"
case PlacementRightEnd:
return "left-full bottom-0 ml-1"
default: // PlacementBottomStart and unknown values
return "top-full left-0 mt-1"
}
}
// FloatingRootProps configures FloatingRoot. Open is the current open state
// (caller-held); OnOpenChange is invoked by children that toggle it. The
// numeric/flip/shift/standalone fields are retained for API parity but are not
// used by the static-positioning approximation (see the file-level NOTE).
type FloatingRootProps struct {
Open bool
OnOpenChange func(bool)
// FloatingOptions configures a Floating. The zero value is usable: bottom-start,
// 4px offset, flip + shift on, closes on outside click and Escape.
type FloatingOptions struct {
Placement string
Offset int
Flip bool
Shift bool
ShiftPadding int
Offset float64
Padding float64
NoFlip bool // inverted so the zero value means "flip", which is what you want
NoShift bool
// ArrowSize is the arrow's width/height in px. Zero means no arrow.
ArrowSize float64
ArrowPadding float64
// Standalone opts out of the single-open manager: opening this panel will not
// close others, and others will not close it. Nested floatings (a submenu
// inside a menu, a select inside a popover) must set it, or opening the child
// would close its own parent.
Standalone bool
Class string
}
// FloatingRoot wraps a Trigger + Content pair. The TSX component rendered no DOM
// node (only a context provider); this port emits a `relative inline-block`
// container so the absolutely-positioned FloatingContent has an anchor.
func FloatingRoot(p FloatingRootProps, children ...*vdom.VNode) *vdom.VNode {
return vdom.El("div", kids([]vdom.Mod{
vdom.Attr("class", cx("relative inline-block", p.Class)),
}, children)...)
}
KeepOnOutsideClick bool // inverted: by default an outside mousedown closes
KeepOnEscape bool // inverted: by default Escape closes the topmost
// FloatingTriggerProps configures FloatingTrigger. Open feeds aria-expanded;
// OnToggle fires on click. OpenOnHover/HoverDelay/HoverCloseDelay are retained
// for API parity but are inert here (hover timers dropped).
type FloatingTriggerProps struct {
Open bool
OnToggle func()
// ConstrainToViewport caps the panel's size on the main axis to the room
// actually available, so a long menu scrolls instead of running off screen.
ConstrainToViewport bool
// OpenOnHover turns the trigger into a hover target. HoverDelay is how long the
// cursor must rest before opening; HoverCloseDelay is the grace period after
// leaving — the "bridge" that lets the cursor cross the gap onto the panel
// without it vanishing. Defaults: 0 and 150ms.
OpenOnHover bool
HoverDelay int
HoverCloseDelay int
Class string
Title string
OnOpenChange func(bool)
}
// FloatingTrigger renders the <button> that toggles the floating content.
//
// NOTE: keyboard activation (Enter/Space to toggle, Escape to close) and
// hover-open/hover-close behavior are dropped — the vdom Event exposes no key,
// and there are no timers. Click toggling via OnToggle is preserved.
func FloatingTrigger(p FloatingTriggerProps, children ...*vdom.VNode) *vdom.VNode {
ariaExpanded := "false"
if p.Open {
ariaExpanded = "true"
var floatingSeq int
// NewFloating creates a floating controller. Call it once per panel, outside the
// render function.
func NewFloating(o FloatingOptions) *Floating {
floatingSeq++
if o.Placement == "" {
o.Placement = PlacementBottomStart
}
if o.Offset == 0 {
o.Offset = 4
}
if o.Padding == 0 {
o.Padding = 8
}
if o.ArrowPadding == 0 {
o.ArrowPadding = 4
}
if o.HoverCloseDelay == 0 {
o.HoverCloseDelay = 150
}
return &Floating{
id: "floating-" + strconv.Itoa(floatingSeq),
opts: o,
open: vdom.NewSignal(false),
resolved: vdom.NewSignal(o.Placement),
triggerRef: vdom.NewRef(),
panelRef: vdom.NewRef(),
arrowRef: vdom.NewRef(),
}
}
// IsOpen reports the current state. Safe to read during render.
func (f *Floating) IsOpen() bool { return f.open.Get() }
// Placement is the resolved placement — after any flip. Read it during render to
// decide which side an arrow or a transition origin belongs on.
func (f *Floating) Placement() string { return f.resolved.Get() }
// Toggle, Show and Hide drive the panel. They are safe to call from event handlers.
func (f *Floating) Toggle() {
if f.open.Get() {
f.Hide()
} else {
f.Show()
}
}
func (f *Floating) Show() {
if f.open.Get() {
return
}
f.cancelHover()
registerOpen(f)
f.open.Set(true)
if f.opts.OnOpenChange != nil {
f.opts.OnOpenChange(true)
}
// The panel does not exist yet — the signal write only *scheduled* a render.
// Measure once it does.
wasmruntime.AfterRender(f.mounted)
}
func (f *Floating) Hide() {
if !f.open.Get() {
return
}
f.cancelHover()
f.teardown()
unregisterOpen(f)
f.open.Set(false)
if f.opts.OnOpenChange != nil {
f.opts.OnOpenChange(false)
}
}
// Dispose closes the panel and removes every listener. Call it if the component
// owning this Floating goes away while the panel might still be open.
func (f *Floating) Dispose() {
f.cancelHover()
f.teardown()
unregisterOpen(f)
if f.open.Get() {
f.open.Set(false)
}
}
// mounted runs once the panel is in the DOM: position it, then start tracking.
func (f *Floating) mounted() {
f.Reposition()
// Reposition when anything that feeds the math changes. `scroll` is registered
// with capture=true because scroll does NOT bubble — capture on window is the
// only way to hear a scroll inside a nested container (a panel anchored to a row
// in a scrollable table depends on this).
f.unsubs = append(f.unsubs,
wasmruntime.OnWindow(vdom.EVENT_SCROLL, true, func(vdom.Event) { f.Reposition() }),
wasmruntime.OnWindow(vdom.EVENT_RESIZE, false, func(vdom.Event) { f.Reposition() }),
// The original kit repositioned only on scroll/resize, so a panel whose own
// content changed size (an async list, a filtered dropdown) stayed at its
// stale position. This is the fix.
wasmruntime.ObserveResize(f.panelRef, f.Reposition),
)
if !f.opts.KeepOnOutsideClick {
// mousedown, not click: it fires before focus moves, so a click that both
// closes this panel and focuses something else behaves predictably.
f.unsubs = append(f.unsubs, wasmruntime.OnDocument(vdom.EVENT_MOUSEDOWN, false, f.onOutside))
}
if !f.opts.KeepOnEscape {
f.unsubs = append(f.unsubs, wasmruntime.OnDocument(vdom.EVENT_KEYDOWN, false, f.onKeydown))
}
}
// Reposition re-runs the math against the live DOM. Cheap enough to call on every
// scroll frame: two measurements, some arithmetic, and a couple of style writes —
// no re-render.
func (f *Floating) Reposition() {
if !f.open.Get() {
return
}
// Self-heal: if the panel was torn out from under us (a route change re-rendered
// the page away), stop tracking rather than leaking listeners forever.
if !f.panelRef.Mounted() {
f.teardown()
return
}
trigger := wasmruntime.Measure(f.triggerRef)
panel := wasmruntime.Measure(f.panelRef)
if trigger.Empty() || panel.Empty() {
return // not laid out yet; stay hidden rather than paint at 0,0
}
pos := ComputePosition(trigger, panel, wasmruntime.Viewport(), f.positionOptions())
wasmruntime.SetStyle(f.panelRef, "top", px(pos.Top))
wasmruntime.SetStyle(f.panelRef, "left", px(pos.Left))
if f.opts.ConstrainToViewport {
if pos.MaxHeight > 0 {
wasmruntime.SetStyle(f.panelRef, "max-height", px(pos.MaxHeight))
wasmruntime.SetStyle(f.panelRef, "overflow-y", "auto")
}
if pos.MaxWidth > 0 {
wasmruntime.SetStyle(f.panelRef, "max-width", px(pos.MaxWidth))
}
}
wasmruntime.SetStyle(f.panelRef, "visibility", "visible")
if f.opts.ArrowSize > 0 && f.arrowRef.Mounted() {
base, _ := splitPlacement(pos.Placement)
if base == "top" || base == "bottom" {
wasmruntime.SetStyle(f.arrowRef, "left", px(pos.ArrowOffset))
wasmruntime.RemoveStyle(f.arrowRef, "top")
} else {
wasmruntime.SetStyle(f.arrowRef, "top", px(pos.ArrowOffset))
wasmruntime.RemoveStyle(f.arrowRef, "left")
}
}
// The arrow's SIDE (which edge it hangs off) is a class, not a style, so a flip
// has to go through a render. Guarded on change, so this converges after one
// extra render instead of looping.
if f.resolved.Get() != pos.Placement {
f.resolved.Set(pos.Placement)
}
}
func (f *Floating) positionOptions() PositionOptions {
return PositionOptions{
Placement: f.opts.Placement,
Offset: f.opts.Offset,
Flip: !f.opts.NoFlip,
Shift: !f.opts.NoShift,
Padding: f.opts.Padding,
ArrowSize: f.opts.ArrowSize,
ArrowPadding: f.opts.ArrowPadding,
}
}
func (f *Floating) teardown() {
for _, un := range f.unsubs {
un()
}
f.unsubs = nil
}
// onOutside closes the panel on a mousedown that landed outside it — unless it
// landed inside a floating that opened LATER, which makes that floating a
// descendant of this one (a submenu of this menu, a select inside this popover).
// Clicking an ANCESTOR still closes this panel, which is what you want.
func (f *Floating) onOutside(ev vdom.Event) {
target := ev.Target()
if wasmruntime.Contains(f.panelRef, target) || wasmruntime.Contains(f.triggerRef, target) {
return
}
if id, ok := wasmruntime.ClosestAttr(target, "[data-floating-id]", "data-floating-id"); ok && openedAfter(id, f.id) {
return
}
f.Hide()
}
// onKeydown closes on Escape — but only the topmost panel, so a menu inside a
// popover closes one layer per press instead of collapsing the whole stack.
func (f *Floating) onKeydown(ev vdom.Event) {
if ev.Key() != vdom.KEY_ESCAPE || !isTopmost(f) {
return
}
ev.PreventDefault()
f.Hide()
}
// ---- hover bridge ----
// The gap between a trigger and its panel is dead space: without a grace period,
// moving the cursor across it closes the panel before you arrive. Both the trigger
// and the panel cancel the pending close on enter and reschedule it on leave, so
// the cursor can travel between them.
func (f *Floating) hoverEnter() {
f.cancelHover()
if f.open.Get() {
return
}
if f.opts.HoverDelay <= 0 {
f.Show()
return
}
f.hoverTimer = wasmruntime.SetTimeout(f.opts.HoverDelay, func() {
f.hoverTimer = 0
f.Show()
})
}
func (f *Floating) hoverLeave() {
f.cancelHover()
f.hoverTimer = wasmruntime.SetTimeout(f.opts.HoverCloseDelay, func() {
f.hoverTimer = 0
f.Hide()
})
}
func (f *Floating) cancelHover() {
if f.hoverTimer != 0 {
wasmruntime.ClearTimeout(f.hoverTimer)
f.hoverTimer = 0
}
}
// ---- the single-open manager + open-order stack ----
var (
// floatingStack is every open floating, in the order they opened. Order is what
// makes "close only the topmost on Escape" and the descendant test above work.
floatingStack []*Floating
// currentSingle is the one non-standalone floating allowed open at a time.
currentSingle *Floating
)
func registerOpen(f *Floating) {
if !f.opts.Standalone && currentSingle != nil && currentSingle != f {
currentSingle.Hide()
}
floatingStack = append(floatingStack, f)
if !f.opts.Standalone {
currentSingle = f
}
}
func unregisterOpen(f *Floating) {
for i, o := range floatingStack {
if o == f {
floatingStack = append(floatingStack[:i], floatingStack[i+1:]...)
break
}
}
if currentSingle == f {
currentSingle = nil
}
}
func isTopmost(f *Floating) bool {
return len(floatingStack) > 0 && floatingStack[len(floatingStack)-1] == f
}
// openedAfter reports whether the floating with the given id opened later than
// self — i.e. is nested inside it.
func openedAfter(id, selfID string) bool {
selfIdx := -1
for i, o := range floatingStack {
if o.id == selfID {
selfIdx = i
break
}
}
if selfIdx < 0 {
return false
}
for _, o := range floatingStack[selfIdx+1:] {
if o.id == id {
return true
}
}
return false
}
// ---- rendering ----
// FloatingTriggerProps configures the element that opens the panel.
type FloatingTriggerProps struct {
Class string
Title string
Tag string // default "button"
AriaHasPopup string
// OnClick runs in addition to the toggle (which is suppressed when the trigger
// opens on hover).
OnClick func()
}
// Trigger renders the element the panel is anchored to.
func (f *Floating) Trigger(p FloatingTriggerProps, children ...*vdom.VNode) *vdom.VNode {
expanded := "false"
if f.open.Get() {
expanded = "true"
}
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.WithRef(f.triggerRef),
vdom.Attr("class", p.Class),
vdom.Attr("aria-expanded", ariaExpanded),
vdom.Attr("aria-haspopup", "menu"),
vdom.Attr("aria-expanded", expanded),
vdom.Attr("aria-haspopup", pick(p.AriaHasPopup, "menu")),
}
tag := pick(p.Tag, "button")
if tag == "button" {
mods = append(mods, vdom.Attr("type", "button"))
}
if p.Title != "" {
mods = append(mods, vdom.Attr("title", p.Title))
}
if p.OnToggle != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
if f.opts.OpenOnHover {
mods = append(mods,
vdom.On(vdom.EVENT_MOUSEENTER, f.hoverEnter),
vdom.On(vdom.EVENT_MOUSELEAVE, f.hoverLeave),
// focus opens it too, so the panel is reachable from the keyboard.
vdom.On(vdom.EVENT_FOCUSIN, f.Show),
vdom.On(vdom.EVENT_FOCUSOUT, f.hoverLeave),
)
if p.OnClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
}
return vdom.El("button", kids(mods, children)...)
} else {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
if p.OnClick != nil {
p.OnClick()
}
f.Toggle()
}))
}
// FloatingContentProps configures FloatingContent. Open toggles visibility;
// Placement picks the static position (see floatingPlacementClass). Style is an
// optional extra inline-style passthrough. OnMouseEnter/OnMouseLeave are wired
// (used by hover popovers to keep themselves open), though the close timer they
// fed in the TSX is gone.
type FloatingContentProps struct {
Open bool
Placement string
// Enter/Space activate a non-button trigger; Escape closes from the trigger.
mods = append(mods, vdom.OnEvent(vdom.EVENT_KEYDOWN, func(ev vdom.Event) {
switch ev.Key() {
case vdom.KEY_ENTER, vdom.KEY_SPACE:
if tag != "button" { // a real button already fires click on Enter/Space
ev.PreventDefault()
f.Toggle()
}
case vdom.KEY_ESCAPE:
f.Hide()
}
}))
return vdom.El(tag, kids(mods, children)...)
}
// FloatingPanelProps configures the floating panel.
type FloatingPanelProps struct {
Class string
Style string
OnMouseEnter func()
OnMouseLeave func()
Role string // default "menu"
}
// FloatingContent renders the popover panel (role="menu"). It is hidden via the
// `hidden` utility while closed rather than unmounted.
// Panel renders the floating content, portaled to document.body.
//
// NOTE: rendered in-flow as an `absolute` element instead of portaled to
// document.body with computed `position: fixed` coordinates; z-[110] preserves
// the TSX's stacking intent (above a z-[100] modal container).
func FloatingContent(p FloatingContentProps, children ...*vdom.VNode) *vdom.VNode {
vis := "hidden"
if p.Open {
vis = "block"
// It is rendered `position: fixed` at 0,0 with `visibility: hidden` — laid out (so
// it can be measured) but not painted. Reposition then writes the real coordinates
// and reveals it. The inline style string is IDENTICAL on every render, which is
// what stops the reconciler's attribute diff from clobbering the imperative
// positions: it only calls setAttribute when the declared value changes.
//
// When closed it renders an EMPTY portal rather than nothing, so its slot in the
// parent's child list never disappears — the reconciler diffs children by index,
// and a vanishing child would shift every sibling after it.
func (f *Floating) Panel(p FloatingPanelProps, children ...*vdom.VNode) *vdom.VNode {
if !f.open.Get() {
return vdom.Portal()
}
mods := []vdom.Mod{
vdom.Attr("role", "menu"),
vdom.Attr("data-floating-content", "true"),
vdom.Attr("class", cx("absolute z-[110]", floatingPlacementClass(p.Placement), vis, p.Class)),
vdom.WithRef(f.panelRef),
vdom.Attr("data-floating-id", f.id),
vdom.Attr("role", pick(p.Role, "menu")),
vdom.Attr("class", cx("z-[110]", p.Class)),
vdom.Attr("style", "position:fixed;top:0;left:0;visibility:hidden"),
}
if p.Style != "" {
mods = append(mods, vdom.Attr("style", p.Style))
if f.opts.OpenOnHover {
mods = append(mods,
vdom.On(vdom.EVENT_MOUSEENTER, f.cancelHover),
vdom.On(vdom.EVENT_MOUSELEAVE, f.hoverLeave),
)
}
if p.OnMouseEnter != nil {
mods = append(mods, vdom.On("mouseenter", p.OnMouseEnter))
return vdom.Portal(vdom.Div(kids(mods, children)...))
}
if p.OnMouseLeave != nil {
mods = append(mods, vdom.On("mouseleave", p.OnMouseLeave))
// Arrow renders the little triangle that points at the trigger. Its side comes
// from the RESOLVED placement, so it follows the panel through a flip; its offset
// along that side is written imperatively by Reposition, so it keeps pointing at
// the trigger even after the panel has been shifted away from it.
//
// Requires FloatingOptions.ArrowSize to be set.
func (f *Floating) Arrow(class string) *vdom.VNode {
side := OppositeSide(f.resolved.Get())
size := px(f.opts.ArrowSize)
// The arrow is a rotated square pinned to the panel's edge; the translate pulls
// it half its own size outside, and centres it on the offset Reposition writes.
var edge, translate string
switch side {
case "top":
edge, translate = "top:0;", "translate(-50%, -50%) rotate(45deg)"
case "bottom":
edge, translate = "bottom:0;", "translate(-50%, 50%) rotate(45deg)"
case "left":
edge, translate = "left:0;", "translate(-50%, -50%) rotate(45deg)"
default: // right
edge, translate = "right:0;", "translate(50%, -50%) rotate(45deg)"
}
return vdom.El("div", kids(mods, children)...)
style := "position:absolute;" + edge +
"width:" + size + ";height:" + size + ";transform:" + translate + ";"
return vdom.Div(vdom.WithRef(f.arrowRef),
vdom.Attr("class", cx("pointer-events-none", class)),
vdom.Attr("style", style),
)
}
func px(v float64) string {
return strconv.FormatFloat(v, 'f', -1, 64) + "px"
}

View File

@@ -159,10 +159,10 @@ func formComboboxRootCls(class, fieldWidth string) string {
func formValidationSpans(errMsg, successMsg string) []vdom.Mod {
var out []vdom.Mod
if errMsg != "" {
out = append(out, vdom.El("span", vdom.Attr("class", formErrorCls), vdom.Text(errMsg)))
out = append(out, vdom.Span(vdom.Attr("class", formErrorCls), vdom.Text(errMsg)))
}
if successMsg != "" {
out = append(out, vdom.El("span", vdom.Attr("class", formSuccessCls), vdom.Text(successMsg)))
out = append(out, vdom.Span(vdom.Attr("class", formSuccessCls), vdom.Text(successMsg)))
}
return out
}
@@ -295,9 +295,9 @@ func formCommonInputMods(p FormInputProps) []vdom.Mod {
func FormInput(p FormInputProps) *vdom.VNode {
class := formInputCls(p.Small, p.Error, p.Success, p.Class, p.OnDark)
inputMods := append([]vdom.Mod{vdom.Attr("class", class)}, formCommonInputMods(p)...)
inner := []vdom.Mod{vdom.Attr("class", "ui-form"), vdom.El("input", inputMods...)}
inner := []vdom.Mod{vdom.Attr("class", "ui-form"), vdom.Input(inputMods...)}
inner = append(inner, formValidationSpans(p.Error, p.Success)...)
return vdom.El("div", inner...)
return vdom.Div(inner...)
}
// FormInputGroup is a FormInput with a leading prefix cell (e.g. a "$" or an
@@ -311,13 +311,13 @@ func FormInputGroup(p FormInputProps, prefix *vdom.VNode) *vdom.VNode {
if prefix != nil {
pfxMods = append(pfxMods, prefix)
}
group := vdom.El("div", vdom.Attr("class", formInputGroupCls),
vdom.El("span", pfxMods...),
vdom.El("span", vdom.Attr("class", "grow flex"), vdom.El("input", inputMods...)),
group := vdom.Div(vdom.Attr("class", formInputGroupCls),
vdom.Span(pfxMods...),
vdom.Span(vdom.Attr("class", "grow flex"), vdom.Input(inputMods...)),
)
inner := []vdom.Mod{vdom.Attr("class", "ui-form"), group}
inner = append(inner, formValidationSpans(p.Error, p.Success)...)
return vdom.El("div", inner...)
return vdom.Div(inner...)
}
// -- specialized single-line inputs --------------------------------------------
@@ -639,9 +639,9 @@ func FormTextarea(p FormTextareaProps) *vdom.VNode {
if p.OnBlur != nil {
mods = append(mods, vdom.On(vdom.EVENT_BLUR, p.OnBlur))
}
inner := []vdom.Mod{vdom.Attr("class", "ui-form"), vdom.El("textarea", mods...)}
inner := []vdom.Mod{vdom.Attr("class", "ui-form"), vdom.Textarea(mods...)}
inner = append(inner, formValidationSpans(p.Error, "")...)
return vdom.El("div", inner...)
return vdom.Div(inner...)
}
// -- FormLabel -----------------------------------------------------------------
@@ -672,7 +672,7 @@ func FormLabel(p FormLabelProps, children ...*vdom.VNode) *vdom.VNode {
if p.Title != "" {
mods = append(mods, vdom.Attr("title", p.Title))
}
return vdom.El("label", kids(mods, children)...)
return vdom.Label(kids(mods, children)...)
}
// -- FormFileInput / FormSpacer / FormFieldset ---------------------------------
@@ -707,20 +707,20 @@ func FormFileInput(p FormInputProps) *vdom.VNode {
h := p.OnChange
mods = append(mods, vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) { h(e.Value()) }))
}
return vdom.El("div", vdom.Attr("class", "ui-form"), vdom.El("input", mods...))
return vdom.Div(vdom.Attr("class", "ui-form"), vdom.Input(mods...))
}
// FormSpacer is vertical spacing between form controls.
func FormSpacer() *vdom.VNode { return vdom.El("div", vdom.Attr("class", "mb-3")) }
func FormSpacer() *vdom.VNode { return vdom.Div(vdom.Attr("class", "mb-3")) }
// FormFieldset wraps children in a bordered <fieldset> with an optional legend.
func FormFieldset(legend, class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("class", cx("border border-neutral-300 rounded-default py-3 px-4", class)),
vdom.El("legend", vdom.Attr("class", "px-2 text-sm font-medium text-neutral-600"), vdom.Text(legend)),
vdom.Legend(vdom.Attr("class", "px-2 text-sm font-medium text-neutral-600"), vdom.Text(legend)),
}
mods = kids(mods, children)
return vdom.El("fieldset", mods...)
return vdom.Fieldset(mods...)
}
// -- FormSelect (native <select>) ----------------------------------------------
@@ -745,7 +745,7 @@ func FormOption(value, label string, disabled bool) *vdom.VNode {
mods = append(mods, vdom.Attr("disabled", "disabled"))
}
mods = append(mods, vdom.Text(label))
return vdom.El("option", mods...)
return vdom.Option(mods...)
}
// FormSelect renders a native <select> (its <option> children are passed in).
@@ -769,7 +769,7 @@ func FormSelect(p FormSelectProps, children ...*vdom.VNode) *vdom.VNode {
mods = append(mods, vdom.OnEvent(vdom.EVENT_CHANGE, func(e vdom.Event) { h(e.Value()) }))
}
mods = kids(mods, children)
return vdom.El("div", vdom.Attr("class", "ui-form"), vdom.El("select", mods...))
return vdom.Div(vdom.Attr("class", "ui-form"), vdom.Select(mods...))
}
// FormStateSelector is a native select pre-populated with USStates.
@@ -862,7 +862,7 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode {
triggerMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", formTriggerCls(p.Small, p.OnDark)),
vdom.El("span", vdom.Attr("class", cx("min-w-0 truncate", placeholderCls)), vdom.Text(display)),
vdom.Span(vdom.Attr("class", cx("min-w-0 truncate", placeholderCls)), vdom.Text(display)),
IconInline(chevron, 16, chevronCls),
}
if p.Disabled {
@@ -874,7 +874,7 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode {
rootMods := []vdom.Mod{
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
vdom.El("button", triggerMods...),
vdom.Button(triggerMods...),
}
if p.Open {
@@ -891,9 +891,8 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode {
searchWrapCls = formDropdownSearchWrapDark
searchInputCls = formDropdownSearchInputDark
}
ddMods = append(ddMods, vdom.El("div", vdom.Attr("class", searchWrapCls),
vdom.El("input",
vdom.Attr("type", "text"),
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", searchWrapCls),
vdom.Input(vdom.Attr("type", "text"),
vdom.Attr("class", searchInputCls),
vdom.Attr("placeholder", pick(p.SearchPlaceholder, "Search...")),
),
@@ -913,7 +912,7 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode {
if p.OnDark {
noResultsCls = formDropdownNoResultsDark
}
ddMods = append(ddMods, vdom.El("div", vdom.Attr("class", noResultsCls), vdom.Text("No options found")))
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", noResultsCls), vdom.Text("No options found")))
}
for _, opt := range p.Options {
ov := opt.Value
@@ -925,11 +924,11 @@ func FormCombobox(p FormComboboxProps) *vdom.VNode {
optMods = append(optMods, vdom.On(vdom.EVENT_CLICK, func() { oc(ov) }))
}
optMods = append(optMods, vdom.Text(opt.Label))
ddMods = append(ddMods, vdom.El("button", optMods...))
ddMods = append(ddMods, vdom.Button(optMods...))
}
rootMods = append(rootMods, vdom.El("div", ddMods...))
rootMods = append(rootMods, vdom.Div(ddMods...))
}
return vdom.El("div", rootMods...)
return vdom.Div(rootMods...)
}
// FormSearchableSelect is a FormCombobox with the search box shown.
@@ -976,14 +975,14 @@ func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
var triggerContent *vdom.VNode
switch {
case len(selected) == 0:
triggerContent = vdom.El("span", vdom.Attr("class", "text-neutral-500"), vdom.Text(pick(p.Placeholder, "Select options")))
triggerContent = vdom.Span(vdom.Attr("class", "text-neutral-500"), vdom.Text(pick(p.Placeholder, "Select options")))
case len(selected) > maxTags:
n := len(selected)
suffix := "s"
if n == 1 {
suffix = ""
}
triggerContent = vdom.El("span", vdom.Text(strconv.Itoa(n)+" item"+suffix+" selected"))
triggerContent = vdom.Span(vdom.Text(strconv.Itoa(n) + " item" + suffix + " selected"))
default:
tagSize := "py-0.5 px-2 text-sm"
if p.Small {
@@ -1006,13 +1005,13 @@ func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
cur := p.Value
rmMods = append(rmMods, vdom.On(vdom.EVENT_CLICK, func() { oc(formRemoveStr(cur, ov)) }))
}
tag := vdom.El("span", vdom.Attr("class", tagCls),
tag := vdom.Span(vdom.Attr("class", tagCls),
vdom.Text(opt.Label),
vdom.El("button", rmMods...),
vdom.Button(rmMods...),
)
tagsMods = append(tagsMods, tag)
}
triggerContent = vdom.El("div", tagsMods...)
triggerContent = vdom.Div(tagsMods...)
}
pad := "p-2"
@@ -1027,7 +1026,7 @@ func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
triggerMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", triggerCls),
vdom.El("div", vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), triggerContent),
vdom.Div(vdom.Attr("class", "flex-1 overflow-hidden flex items-center min-w-0"), triggerContent),
IconInline(chevron, 16, "text-neutral-400"),
}
if p.Disabled {
@@ -1039,16 +1038,15 @@ func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
rootMods := []vdom.Mod{
vdom.Attr("class", formComboboxRootCls(p.Class, p.FieldWidth)),
vdom.El("button", triggerMods...),
vdom.Button(triggerMods...),
}
if p.Open {
ddMods := []vdom.Mod{vdom.Attr("class", cx("absolute left-0 top-full mt-1 z-50 w-full", formDropdown))}
if p.Searchable {
ddMods = append(ddMods, vdom.El("div", vdom.Attr("class", formDropdownSearchWrap),
vdom.El("input",
vdom.Attr("type", "text"),
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formDropdownSearchWrap),
vdom.Input(vdom.Attr("type", "text"),
vdom.Attr("class", formDropdownSearchInput),
vdom.Attr("placeholder", pick(p.SearchPlaceholder, "Search...")),
),
@@ -1075,11 +1073,11 @@ func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
}
}))
}
ddMods = append(ddMods, vdom.El("div", vdom.Attr("class", formSelectAllWrap), vdom.El("button", saMods...)))
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formSelectAllWrap), vdom.Button(saMods...)))
}
if len(p.Options) == 0 {
ddMods = append(ddMods, vdom.El("div", vdom.Attr("class", formDropdownNoResults), vdom.Text("No options found")))
ddMods = append(ddMods, vdom.Div(vdom.Attr("class", formDropdownNoResults), vdom.Text("No options found")))
}
for _, opt := range p.Options {
ov := opt.Value
@@ -1090,7 +1088,7 @@ func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
optMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", formDropdownOption),
vdom.El("input", cbMods...),
vdom.Input(cbMods...),
vdom.Text(opt.Label),
}
if opt.Disabled {
@@ -1100,11 +1098,11 @@ func FormMultiSelect(p FormMultiSelectProps) *vdom.VNode {
cur := p.Value
optMods = append(optMods, vdom.On(vdom.EVENT_CLICK, func() { oc(formToggleStr(cur, ov)) }))
}
ddMods = append(ddMods, vdom.El("button", optMods...))
ddMods = append(ddMods, vdom.Button(optMods...))
}
rootMods = append(rootMods, vdom.El("div", ddMods...))
rootMods = append(rootMods, vdom.Div(ddMods...))
}
return vdom.El("div", rootMods...)
return vdom.Div(rootMods...)
}
// -- []string selection helpers (for FormMultiSelect) --------------------------

View File

@@ -262,9 +262,9 @@ func fuzzyHighlight(segments []FuzzySegment) []*vdom.VNode {
out := make([]*vdom.VNode, 0, len(segments))
for _, seg := range segments {
if seg.Match {
out = append(out, vdom.El("span", vdom.Attr("class", "text-sky-700 font-semibold"), vdom.Text(seg.Text)))
out = append(out, vdom.Span(vdom.Attr("class", "text-sky-700 font-semibold"), vdom.Text(seg.Text)))
} else {
out = append(out, vdom.El("span", vdom.Text(seg.Text)))
out = append(out, vdom.Span(vdom.Text(seg.Text)))
}
}
return out
@@ -275,7 +275,7 @@ func fuzzyScoreBadge(show bool, score int) *vdom.VNode {
if !show {
return nil
}
return vdom.El("span", vdom.Attr("class", "ml-3 shrink-0 text-xs text-neutral-400"), vdom.Text(strconv.Itoa(score)))
return vdom.Span(vdom.Attr("class", "ml-3 shrink-0 text-xs text-neutral-400"), vdom.Text(strconv.Itoa(score)))
}
// fuzzyListView renders the inline "list" display. Before the user types, all
@@ -301,19 +301,18 @@ func fuzzyListView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode {
if p.OnSelect != nil {
li = append(li, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
}
li = append(li, vdom.El("span", kids([]vdom.Mod{vdom.Attr("class", "text-sm text-neutral-800")}, fuzzyHighlight(r.Segments))...))
li = append(li, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-sm text-neutral-800")}, fuzzyHighlight(r.Segments))...))
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
li = append(li, badge)
}
ul = append(ul, vdom.El("li", li...))
ul = append(ul, vdom.Li(li...))
}
outer = append(outer, vdom.El("ul", ul...))
outer = append(outer, vdom.Ul(ul...))
} else if strings.TrimSpace(p.Query) != "" {
outer = append(outer, vdom.El("p",
vdom.Attr("class", "text-sm text-neutral-500 italic"),
outer = append(outer, vdom.P(vdom.Attr("class", "text-sm text-neutral-500 italic"),
vdom.Text(`No matches for "`+p.Query+`".`)))
}
return vdom.El("div", outer...)
return vdom.Div(outer...)
}
// fuzzyDropdownView renders the "dropdown" display's option panel.
@@ -339,13 +338,13 @@ func fuzzyDropdownView(p FuzzyMatchProps, results []FuzzyRankedItem) *vdom.VNode
if p.OnSelect != nil {
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, func() { p.OnSelect(r.Value, r) }))
}
btnMods = append(btnMods, vdom.El("span", kids([]vdom.Mod{vdom.Attr("class", "text-neutral-800")}, fuzzyHighlight(r.Segments))...))
btnMods = append(btnMods, vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "text-neutral-800")}, fuzzyHighlight(r.Segments))...))
if badge := fuzzyScoreBadge(p.ShowScores, r.Score); badge != nil {
btnMods = append(btnMods, badge)
}
mods = append(mods, vdom.El("button", btnMods...))
mods = append(mods, vdom.Button(btnMods...))
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}
// FuzzyMatch renders a fuzzy-search input with inline ("list"), autocomplete
@@ -377,7 +376,7 @@ func FuzzyMatch(p FuzzyMatchProps) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("class", cx("relative", p.Class)),
vdom.El("input", inputMods...),
vdom.Input(inputMods...),
}
switch display {
@@ -388,5 +387,5 @@ func FuzzyMatch(p FuzzyMatchProps) *vdom.VNode {
mods = append(mods, fuzzyDropdownView(p, results))
}
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}

View File

@@ -7,26 +7,25 @@ import "kjol/vdom"
// PageContainer wraps a page body (marker class kept for app CSS).
func PageContainer(children ...*vdom.VNode) *vdom.VNode {
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", "admin-page-container")}, children)...)
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "admin-page-container")}, children)...)
}
// Divider is a thin horizontal rule.
func Divider() *vdom.VNode { return vdom.El("hr", vdom.Attr("class", "text-neutral-200 mt-1 mb-3")) }
func Divider() *vdom.VNode { return vdom.Hr(vdom.Attr("class", "text-neutral-200 mt-1 mb-3")) }
// CodeBox renders a dark monospace code block.
func CodeBox(code, class string) *vdom.VNode {
return vdom.El("div",
vdom.Attr("class", cx("text-xs p-3 bg-neutral-800 text-neutral-100 border border-neutral-700 rounded-default", class)),
vdom.El("pre", vdom.El("code", vdom.Text(code))),
return vdom.Div(vdom.Attr("class", cx("text-xs p-3 bg-neutral-800 text-neutral-100 border border-neutral-700 rounded-default", class)),
vdom.Pre(vdom.Code(vdom.Text(code))),
)
}
// PageHeader is a centered page title with an underline.
func PageHeader(text, class string) *vdom.VNode {
return vdom.El("header", vdom.Attr("class", class),
vdom.El("div", vdom.Attr("class", "mt-1"),
vdom.El("h1", vdom.Attr("class", "text-center text-2xl font-light text-neutral-800 mb-2"), vdom.Text(text)),
vdom.El("hr", vdom.Attr("class", "text-neutral-200 mb-2")),
return vdom.Header(vdom.Attr("class", class),
vdom.Div(vdom.Attr("class", "mt-1"),
vdom.H1(vdom.Attr("class", "text-center text-2xl font-light text-neutral-800 mb-2"), vdom.Text(text)),
vdom.Hr(vdom.Attr("class", "text-neutral-200 mb-2")),
),
)
}
@@ -40,13 +39,13 @@ func PageLink(href string, newTab bool, class string, children ...*vdom.VNode) *
if newTab {
mods = append(mods, vdom.Attr("target", "_blank"), vdom.Attr("rel", "noopener noreferrer"))
}
return vdom.El("a", kids(mods, children)...)
return vdom.A(kids(mods, children)...)
}
// Loader is a centered spinning ring.
func Loader() *vdom.VNode {
return vdom.El("div", vdom.Attr("class", "flex items-center justify-center p-8"),
vdom.El("div", vdom.Attr("class", "h-8 w-8 border-4 border-sky-700 border-t-transparent rounded-full animate-spin")),
return vdom.Div(vdom.Attr("class", "flex items-center justify-center p-8"),
vdom.Div(vdom.Attr("class", "h-8 w-8 border-4 border-sky-700 border-t-transparent rounded-full animate-spin")),
)
}
@@ -62,31 +61,29 @@ func Breadcrumbs(items []BreadcrumbItem) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", "flex flex-row items-center text-neutral-400 text-xs")}
for i, crumb := range items {
if i != len(items)-1 {
mods = append(mods, vdom.El("span", vdom.Attr("class", "flex items-center"),
vdom.El("a", vdom.Attr("href", crumb.URL),
mods = append(mods, vdom.Span(vdom.Attr("class", "flex items-center"),
vdom.A(vdom.Attr("href", crumb.URL),
vdom.Attr("class", "text-neutral-500 cursor-pointer no-underline hover:text-neutral-700 hover:underline"),
vdom.Text(crumb.DisplayText)),
Icon("chevron-right", 12, "mx-[0.15rem] opacity-50"),
))
} else {
mods = append(mods, vdom.El("span", vdom.Attr("class", "text-neutral-700 font-medium"), vdom.Text(crumb.DisplayText)))
mods = append(mods, vdom.Span(vdom.Attr("class", "text-neutral-700 font-medium"), vdom.Text(crumb.DisplayText)))
}
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}
// ManagerPageHeader is a title/description row with an optional action slot
// (marker classes kept for app CSS).
func ManagerPageHeader(title, description string, action *vdom.VNode) *vdom.VNode {
inner := vdom.El("div",
vdom.El("h2", vdom.Attr("class", "page-title"), vdom.Text(title)),
)
inner := vdom.Div(vdom.H2(vdom.Attr("class", "page-title"), vdom.Text(title)))
if description != "" {
inner.Children = append(inner.Children, vdom.El("p", vdom.Attr("class", "page-desc"), vdom.Text(description)))
inner.Children = append(inner.Children, vdom.P(vdom.Attr("class", "page-desc"), vdom.Text(description)))
}
mods := []vdom.Mod{vdom.Attr("class", "page-header"), inner}
if action != nil {
mods = append(mods, action)
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}

View File

@@ -39,8 +39,7 @@ func Icon(name string, size int, class string) *vdom.VNode {
content = def.content
}
s := strconv.Itoa(size)
return vdom.El("svg",
vdom.Attr("xmlns", "http://www.w3.org/2000/svg"),
return vdom.Svg(vdom.Attr("xmlns", "http://www.w3.org/2000/svg"),
vdom.Attr("viewBox", vb),
vdom.Attr("fill", "currentColor"),
vdom.Attr("width", s), vdom.Attr("height", s),
@@ -65,30 +64,83 @@ func IconError(name string, size int, class string) *vdom.VNode {
// IconContainer is a flex row that vertically centers an icon + text.
func IconContainer(children ...*vdom.VNode) *vdom.VNode {
return vdom.El("span", kids([]vdom.Mod{vdom.Attr("class", "flex flex-row items-center gap-2")}, children)...)
return vdom.Span(kids([]vdom.Mod{vdom.Attr("class", "flex flex-row items-center gap-2")}, children)...)
}
// A small default set of stroke-based icons (Heroicons outline, 24x24) so common
// components render out of the box. Apps register more via RegisterIcon.
// The default icon set: Heroicons-style outline paths on a 24x24 viewBox, so the
// kit renders correctly out of the box. Apps override or extend with RegisterIcon.
//
// Every name the kit itself asks for MUST be here. An unregistered name renders as
// an empty (correctly sized) box, which is a silent failure — the layout is right
// and the glyph is simply missing. That is what "the icons aren't showing" looks
// like, so if you add an Icon("…") call to a component, register the name too.
func strokePath(d string) string {
return `<path stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none" d="` + d + `"/>`
return `<path stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" fill="none" d="` + d + `"/>`
}
// Some glyphs read badly as outlines at 12-16px — a caret, a drag grip, an
// ellipsis. Those are filled shapes.
func fillPath(d string) string {
return `<path fill="currentColor" fill-rule="evenodd" clip-rule="evenodd" d="` + d + `"/>`
}
func init() {
reg := func(name, d string) { RegisterIcon(name, "0 0 24 24", strokePath(d)) }
solid := func(name, d string) { RegisterIcon(name, "0 0 24 24", fillPath(d)) }
// --- navigation / chevrons ---
reg("chevron-left", "M15.75 19.5 8.25 12l7.5-7.5")
reg("chevron-right", "m8.25 4.5 7.5 7.5-7.5 7.5")
reg("chevron-down", "m19.5 8.25-7.5 7.5-7.5-7.5")
reg("chevron-up", "m4.5 15.75 7.5-7.5 7.5 7.5")
reg("angles-left", "M18.75 19.5 11.25 12l7.5-7.5M12.75 19.5 5.25 12l7.5-7.5")
reg("angles-right", "m5.25 4.5 7.5 7.5-7.5 7.5m6-15 7.5 7.5-7.5 7.5")
reg("arrow-right", "M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3")
reg("arrow-right-from-bracket", "M15.75 9V5.25A2.25 2.25 0 0 0 13.5 3h-6a2.25 2.25 0 0 0-2.25 2.25v13.5A2.25 2.25 0 0 0 7.5 21h6a2.25 2.25 0 0 0 2.25-2.25V15M12 9l-3 3m0 0 3 3m-3-3h12.75")
reg("external-link", "M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25")
// The sort carets: filled triangles. As outlines they are a smudge at 16px.
solid("caret-up", "M12 7.5 5.25 15.75h13.5L12 7.5Z")
solid("caret-down", "M12 16.5 5.25 8.25h13.5L12 16.5Z")
// --- actions ---
reg("check", "m4.5 12.75 6 6 9-13.5")
reg("xmark", "M6 18 18 6M6 6l12 12")
reg("x", "M6 18 18 6M6 6l12 12")
reg("plus", "M12 4.5v15m7.5-7.5h-15")
reg("minus", "M4.5 12h15")
reg("bars", "M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5")
reg("arrow-right", "M13.5 4.5 21 12m0 0-7.5 7.5M21 12H3")
reg("external-link", "M13.5 6H5.25A2.25 2.25 0 0 0 3 8.25v10.5A2.25 2.25 0 0 0 5.25 21h10.5A2.25 2.25 0 0 0 18 18.75V10.5m-10.5 6L21 3m0 0h-5.25M21 3v5.25")
reg("info", "M11.25 11.25h1.5v4.5M12 8.25h.008M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
reg("triangle-exclamation", "M12 9v3.75m0 3.75h.008M10.34 3.94 1.7 18a1.5 1.5 0 0 0 1.3 2.25h18a1.5 1.5 0 0 0 1.3-2.25L13.66 3.94a1.5 1.5 0 0 0-2.6 0Z")
reg("pencil", "m16.86 4.49 1.69-1.69a1.875 1.875 0 1 1 2.65 2.65L10.58 16.07a4.5 4.5 0 0 1-1.9 1.13L6 18l.8-2.69a4.5 4.5 0 0 1 1.13-1.9l8.93-8.92Zm0 0 2.64 2.64")
reg("trash", "m14.74 9-.35 9m-4.79 0L9.26 9m9.97-3.21c.34.05.68.11 1.02.17M18.16 19.67a2.25 2.25 0 0 1-2.24 2.08H8.08a2.25 2.25 0 0 1-2.24-2.08L4.77 5.79m14.46 0a48.1 48.1 0 0 0-3.48-.4m-12 .57c.34-.06.68-.12 1.02-.17m0 0a48.1 48.1 0 0 1 3.48-.4m7.5 0v-.92c0-1.18-.91-2.16-2.09-2.2a51.96 51.96 0 0 0-3.32 0c-1.18.04-2.09 1.02-2.09 2.2v.92m7.5 0a48.67 48.67 0 0 0-7.5 0")
solid("ellipsis", "M6.75 12a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm6.75 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm6.75 0a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z")
// The drag grip: six dots. An outline version is unreadable at 12px.
solid("grip-vertical", "M10 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM10 12a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm0 6.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM17 5.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0ZM17 12a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Zm0 6.5a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z")
// --- table chrome ---
reg("filter", "M12 3c2.76 0 5.46.23 8.08.68.53.09.92.56.92 1.1v1.04a2.25 2.25 0 0 1-.66 1.59l-5.43 5.43a2.25 2.25 0 0 0-.66 1.59v2.93a2.25 2.25 0 0 1-1.24 2.01L9.75 21v-6.57a2.25 2.25 0 0 0-.66-1.59L3.66 7.41A2.25 2.25 0 0 1 3 5.82V4.77c0-.54.38-1 .92-1.1A48.3 48.3 0 0 1 12 3Z")
reg("table-columns", "M3.75 6.75h16.5M3.75 12h16.5m-16.5 5.25h16.5M9 3.75v16.5m6-16.5v16.5")
reg("list-ol", "M8.25 6.75h12M8.25 12h12m-12 5.25h12M3.75 6.75h.01v.01h-.01v-.01Zm0 5.25h.01v.01h-.01V12Zm0 5.25h.01v.01h-.01v-.01Z")
reg("calculator", "M15.75 15.75V18M8.25 11.25h.01m-.01 2.25h.01m-.01 2.25h.01m-.01 2.25h.01m2.49-6.75h.01m-.01 2.25h.01m-.01 2.25h.01m-.01 2.25h.01m2.5-6.75h.01m-.01 2.25h.01m-.01 2.25h.01m-.01 2.25h.01m2.49-6.75h.01m-.01 2.25h.01M8.25 6h7.5v2.25h-7.5V6ZM12 2.25c-1.89 0-3.76.11-5.59.32-1.1.13-1.91 1.08-1.91 2.19V19.5a2.25 2.25 0 0 0 2.25 2.25h10.5a2.25 2.25 0 0 0 2.25-2.25V4.76c0-1.11-.81-2.06-1.91-2.19A48.5 48.5 0 0 0 12 2.25Z")
// --- export ---
reg("download", "M3 16.5v2.25A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75V16.5M16.5 12 12 16.5m0 0L7.5 12m4.5 4.5V3")
reg("print", "M6.72 13.83c-.24.03-.48.06-.72.1m.72-.1a42.4 42.4 0 0 1 10.56 0m-10.56 0L6.34 18m10.94-4.17c.24.03.48.06.72.1m-.72-.1L17.66 18m0 0 .23 2.52a1.13 1.13 0 0 1-1.12 1.23H7.23c-.66 0-1.18-.57-1.12-1.23L6.34 18m11.32 0h1.09A2.25 2.25 0 0 0 21 15.75V9.46c0-1.09-.77-2.02-1.84-2.18-.63-.1-1.27-.18-1.91-.25M6.34 18H5.25A2.25 2.25 0 0 1 3 15.75V9.46c0-1.09.77-2.02 1.84-2.18.63-.1 1.27-.18 1.91-.25m10.5 0a48.5 48.5 0 0 0-10.5 0m10.5 0V3.38c0-.63-.5-1.13-1.13-1.13h-8.25c-.62 0-1.12.5-1.12 1.13v3.65")
reg("file", "M19.5 14.25v-2.63a3.38 3.38 0 0 0-3.38-3.37h-1.5a1.13 1.13 0 0 1-1.12-1.13v-1.5a3.38 3.38 0 0 0-3.38-3.37H8.25m2.25 0H5.63c-.63 0-1.13.5-1.13 1.12v17.25c0 .63.5 1.13 1.13 1.13h12.74c.63 0 1.13-.5 1.13-1.13V11.25a9 9 0 0 0-9-9Z")
reg("file-csv", "M19.5 14.25v-2.63a3.38 3.38 0 0 0-3.38-3.37h-1.5a1.13 1.13 0 0 1-1.12-1.13v-1.5a3.38 3.38 0 0 0-3.38-3.37H8.25m2.25 0H5.63c-.63 0-1.13.5-1.13 1.12v17.25c0 .63.5 1.13 1.13 1.13h12.74c.63 0 1.13-.5 1.13-1.13V11.25a9 9 0 0 0-9-9Zm-3 12h6m-6 3h6")
reg("file-pdf", "M19.5 14.25v-2.63a3.38 3.38 0 0 0-3.38-3.37h-1.5a1.13 1.13 0 0 1-1.12-1.13v-1.5a3.38 3.38 0 0 0-3.38-3.37H8.25m2.25 0H5.63c-.63 0-1.13.5-1.13 1.12v17.25c0 .63.5 1.13 1.13 1.13h12.74c.63 0 1.13-.5 1.13-1.13V11.25a9 9 0 0 0-9-9Zm-3 11.25v4.5m0-4.5h1.88a1.13 1.13 0 0 1 0 2.25H7.5")
// --- fields ---
reg("calendar", "M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 0 1 2.25-2.25h13.5A2.25 2.25 0 0 1 21 7.5v11.25m-18 0A2.25 2.25 0 0 0 5.25 21h13.5A2.25 2.25 0 0 0 21 18.75m-18 0v-7.5A2.25 2.25 0 0 1 5.25 9h13.5A2.25 2.25 0 0 1 21 11.25v7.5")
reg("envelope", "M21.75 6.75v10.5a2.25 2.25 0 0 1-2.25 2.25h-15a2.25 2.25 0 0 1-2.25-2.25V6.75m19.5 0A2.25 2.25 0 0 0 19.5 4.5h-15a2.25 2.25 0 0 0-2.25 2.25m19.5 0v.24a2.25 2.25 0 0 1-1.07 1.92l-7.5 4.62a2.25 2.25 0 0 1-2.36 0L3.32 8.91a2.25 2.25 0 0 1-1.07-1.92v-.24")
reg("globe", "M12 21a9 9 0 0 0 8.72-6.75M12 21a9 9 0 0 1-8.72-6.75M12 21c2.49 0 4.5-4.03 4.5-9S14.49 3 12 3m0 18c-2.49 0-4.5-4.03-4.5-9S9.51 3 12 3m0 0a9 9 0 0 1 7.84 4.58M12 3a9 9 0 0 0-7.84 4.58m15.68 0A11.95 11.95 0 0 1 12 10.5c-3 0-5.74-1.1-7.84-2.92m15.68 0A8.96 8.96 0 0 1 21 12c0 .78-.1 1.53-.28 2.25m0 0A17.92 17.92 0 0 1 12 16.5c-3.16 0-6.13-.82-8.72-2.25m0 0A9.02 9.02 0 0 1 3 12c0-1.6.42-3.11 1.16-4.42")
reg("user", "M15.75 6a3.75 3.75 0 1 1-7.5 0 3.75 3.75 0 0 1 7.5 0ZM4.5 20.12a7.5 7.5 0 0 1 15 0A17.93 17.93 0 0 1 12 21.75c-2.68 0-5.22-.58-7.5-1.63Z")
// --- status (the toast set) ---
reg("info", "M11.25 11.25h1.5v4.5M12 8.25h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
reg("circle-info", "M11.25 11.25h1.5v4.5M12 8.25h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
reg("circle-check", "M9 12.75 11.25 15 15 9.75M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
reg("circle-xmark", "m9.75 9.75 4.5 4.5m0-4.5-4.5 4.5M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
reg("circle-exclamation", "M12 9v3.75m0 3h.01M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0Z")
reg("triangle-exclamation", "M12 9v3.75m0 3.75h.01M10.34 3.94 1.7 18a1.5 1.5 0 0 0 1.3 2.25h18a1.5 1.5 0 0 0 1.3-2.25L13.66 3.94a1.5 1.5 0 0 0-2.6 0Z")
}

116
go/webui/icons_test.go Normal file
View File

@@ -0,0 +1,116 @@
package webui
import (
"os"
"path/filepath"
"regexp"
"sort"
"strings"
"testing"
"kjol/vdom"
)
// An unregistered icon name renders as an empty box: right size, no glyph. Nothing
// errors, nothing logs — the icons just quietly aren't there. That is exactly how
// this broke, so scan our own source for every name we ask for and assert the
// registry has it.
func TestEveryIconTheKitUsesIsRegistered(t *testing.T) {
// Icon("name", …), IconInline("name", …), and the Icon: "name" struct field.
calls := regexp.MustCompile(`\bIcon(?:Inline|Success|Error)?\("([a-z0-9-]+)"`)
fields := regexp.MustCompile(`\bIcon:\s*"([a-z0-9-]+)"`)
files, err := filepath.Glob("*.go")
if err != nil {
t.Fatal(err)
}
used := map[string][]string{} // icon name -> files that ask for it
for _, f := range files {
if strings.HasSuffix(f, "_test.go") || f == "icons.go" {
continue // icons.go is the registry itself
}
src, err := os.ReadFile(f)
if err != nil {
t.Fatal(err)
}
for _, re := range []*regexp.Regexp{calls, fields} {
for _, m := range re.FindAllStringSubmatch(string(src), -1) {
if m[1] == "" {
continue
}
used[m[1]] = append(used[m[1]], f)
}
}
}
// Icon names also reach Icon() through lookup tables. Those cannot be found by
// scanning call sites, so add them from the tables themselves.
for _, name := range toastTypeIcons {
if name != "" {
used[name] = append(used[name], "toast.go (toastTypeIcons)")
}
}
if len(used) == 0 {
t.Fatal("scanned no icon usages — the regexes have rotted")
}
var missing []string
for name, where := range used {
if _, ok := iconRegistry[name]; !ok {
missing = append(missing, name+" (used in "+strings.Join(dedupe(where), ", ")+")")
}
}
sort.Strings(missing)
for _, m := range missing {
t.Errorf("icon not registered, renders as an empty box: %s", m)
}
}
// A registered icon must actually draw something. An entry with empty content is
// the same silent failure as a missing one.
func TestRegisteredIconsHaveContent(t *testing.T) {
for name, def := range iconRegistry {
if strings.TrimSpace(def.content) == "" {
t.Errorf("icon %q is registered with no SVG content", name)
}
if !strings.Contains(def.content, "<path") {
t.Errorf("icon %q has no <path>: %q", name, def.content)
}
if def.viewBox == "" {
t.Errorf("icon %q has no viewBox", name)
}
}
}
func TestIconRendersRegisteredContent(t *testing.T) {
html := renderNode(Icon("check", 20, "text-red-500"))
if !strings.Contains(html, "<path") {
t.Errorf("Icon did not render its path: %s", html)
}
for _, want := range []string{`width="20"`, `height="20"`, "text-red-500", `viewBox="0 0 24 24"`} {
if !strings.Contains(html, want) {
t.Errorf("Icon output missing %s: %s", want, html)
}
}
// An unknown name still renders a correctly sized, empty box rather than
// collapsing the layout.
if empty := renderNode(Icon("no-such-icon", 16, "")); strings.Contains(empty, "<path") {
t.Errorf("unknown icon drew something: %s", empty)
}
}
// renderNode serializes a component's VNode the way the server does.
func renderNode(n *vdom.VNode) string { return vdom.RenderHTML(n) }
func dedupe(xs []string) []string {
seen := map[string]bool{}
out := []string{}
for _, x := range xs {
if !seen[x] {
seen[x] = true
out = append(out, x)
}
}
sort.Strings(out)
return out
}

View File

@@ -2,24 +2,60 @@ package webui
import "kjol/vdom"
// Port of web/kit/Menu.tsx.
// Port of web/uikit/Menu.tsx, rebuilt on the Floating controller (floating.go).
//
// NOTE: floating-ui positioning (FloatingRoot / FloatingTrigger / FloatingContent
// / useFloatingContext / useFloatingHover) is dropped. Menu is a `relative`
// wrapper and MenuContent is an `absolute` dropdown positioned with static
// Tailwind utilities chosen from MenuPlacement; there is no viewport-aware
// collision detection.
// NOTE: Solid's MenuContext (closeMenu / openOnHover / cancelParentClose) is
// dropped. Open state is a passed bool: MenuContent and Submenu render only when
// open, and the caller toggles it via MenuTrigger's onToggle. Item clicks no
// longer auto-close the menu (closeOnClick / closeMenu removed) — the caller
// closes it from its own click handler.
// NOTE: hover-open/hover-close timers, the MenuTrigger render-prop (isOpen state)
// and asChild, Submenu's getBoundingClientRect positioning with scroll/resize
// listeners, and MenuItem's Enter/Space keydown handler (the runtime's Event
// exposes no key) are all dropped.
// NOTE: the imported "Placement" type is renamed MenuPlacement to avoid colliding
// with a future Floating port.
// # Why the API is a controller with methods
//
// The TSX threads three things through a Solid MenuContext: closeMenu (so an item
// click dismisses the menu it lives in), the hover settings, and cancelParentClose
// (so moving the cursor onto a submenu does not close the menu that spawned it).
// Go has no context — so the menu IS the object. *Menu is a controller you create
// once, next to your signals and NEVER inside a render function (it owns a
// Floating, which owns DOM refs; rebuilding it every frame would throw those away
// each render). Every part of the menu is a method on it, and the receiver is the
// answer to "which menu does this close":
//
// menu := webui.NewMenu(webui.MenuOptions{Placement: webui.PlacementBottomEnd})
// sub := webui.NewSubmenu(menu) // a submenu is owned by its parent menu
//
// return func() *vdom.VNode {
// return vdom.Div(
// menu.TriggerFunc(webui.MenuTriggerProps{Class: "btn"}, func(open bool) *vdom.VNode {
// return webui.Icon(chevronFor(open), 16, "") // the render-prop: chevron follows open state
// }),
// menu.Content("",
// menu.Item(webui.MenuItemProps{Icon: "user"}, vdom.Text("Profile")),
// MenuDivider(""),
// sub.Submenu(webui.SubmenuProps{Trigger: "Export", Icon: "download"},
// sub.Item(webui.MenuItemProps{OnClick: exportCSV}, vdom.Text("CSV")),
// ),
// ),
// )
// }
//
// Binding the close to the receiver is the whole point. The previous port dropped
// MenuContext, which left items with no way to close their menu: every menu in both
// consuming apps stayed open after you clicked an item. A method cannot forget its
// receiver; a `Menu *Menu` field in a props struct can be — and eventually would be —
// left out, silently reintroducing exactly that bug.
//
// # Signature changes from the previous port (all forced by the above)
//
// - Menu is now a TYPE (the controller), not a wrapper element. There is no
// `relative inline-block` wrapper any more: the panel is portaled to
// document.body and positioned by measurement, so it needs no positioned
// ancestor — and, crucially, can no longer be CLIPPED by one. menuCls carries
// `overflow-y-auto`, so the old in-flow submenu was clipped by its own parent
// menu the moment it was taller or wider than the scroll box.
// - MenuTrigger / MenuContent / MenuItem / MenuLink / MenuAnchor / Submenu are
// methods (Trigger / TriggerFunc / Content / Item / Link / Anchor / Submenu).
// - MenuPlacement and its constants are gone; placement is one of the Placement*
// strings from position.go, which is what Floating speaks.
// - MenuItemProps.KeepOpen replaces the TSX's closeOnClick, inverted so Go's zero
// value gives the TSX default (closeOnClick: true — items close the menu).
//
// MenuDivider, MenuSection and MenuGroup stay free functions: they hold no state
// and have nothing to close.
const menuCls = "bg-white rounded-default shadow-lg border border-neutral-200 p-1.5 min-w-48 max-h-96 overflow-y-auto"
const menuItemCls = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 bg-transparent border-0 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900 focus:bg-neutral-100 focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
@@ -27,104 +63,292 @@ const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-neutral-200"
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-neutral-400 uppercase tracking-wide text-left"
const menuSubmenuTriggerCls = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-neutral-700 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900"
// MenuPlacement selects where MenuContent is positioned relative to its trigger.
type MenuPlacement string
// defaultHoverCloseDelay is the TSX's 150ms grace period after the cursor leaves.
const defaultHoverCloseDelay = 150
const (
MenuPlacementBottomStart MenuPlacement = "bottom-start"
MenuPlacementBottomEnd MenuPlacement = "bottom-end"
MenuPlacementTopStart MenuPlacement = "top-start"
MenuPlacementTopEnd MenuPlacement = "top-end"
MenuPlacementLeftStart MenuPlacement = "left-start"
MenuPlacementRightStart MenuPlacement = "right-start"
)
// MenuOptions configures NewMenu. The zero value is a click-to-open menu, placed
// bottom-start with the standard 4px offset.
type MenuOptions struct {
// Placement is one of the Placement* constants (default PlacementBottomStart).
Placement string
// Offset is the gap between trigger and panel, in px (default 4).
Offset float64
// menuPlacementClasses maps a placement to the static absolute-position utilities
// (the ~4px offset becomes the mt-1/mb-1/ml-1/mr-1 margin).
var menuPlacementClasses = map[MenuPlacement]string{
MenuPlacementBottomStart: "top-full left-0 mt-1",
MenuPlacementBottomEnd: "top-full right-0 mt-1",
MenuPlacementTopStart: "bottom-full left-0 mb-1",
MenuPlacementTopEnd: "bottom-full right-0 mb-1",
MenuPlacementLeftStart: "right-full top-0 mr-1",
MenuPlacementRightStart: "left-full top-0 ml-1",
// OpenOnHover turns the trigger into a hover target (it still opens on click).
// HoverDelay is how long the cursor must rest before it opens; HoverCloseDelay
// is the grace period after leaving — the bridge that lets the cursor cross the
// gap onto the panel without it vanishing (default 150ms). Submenus inherit
// HoverCloseDelay, exactly as they inherited it through the TSX's MenuContext.
OpenOnHover bool
HoverDelay int
HoverCloseDelay int
// Standalone opts this menu out of the single-open manager. Set it for a menu
// that lives INSIDE another floating — an insert menu in a popover form, say —
// which would otherwise be read as "a rival panel opened" and close the very
// popover it belongs to. (A submenu gets this automatically; see NewSubmenu.)
Standalone bool
OnOpenChange func(bool)
}
// Menu is the positioning context: a relative wrapper holding a MenuTrigger and a
// MenuContent.
func Menu(class string, children ...*vdom.VNode) *vdom.VNode {
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("relative inline-block", class))}, children)...)
// Menu is the controller for one dropdown menu (or, via NewSubmenu, one nested
// submenu). Create it once — outside the render function — and render its parts
// with Trigger/TriggerFunc, Content, Item, Link, Anchor and Submenu.
type Menu struct {
f *Floating
parent *Menu // nil for a root menu
subs []*Menu // submenus opened from this menu; closed when this menu closes
hoverCloseDelay int
}
// MenuTrigger wraps the clickable element that toggles the menu open/closed.
func MenuTrigger(onToggle func(), class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("class", class),
vdom.Attr("aria-haspopup", "menu"),
// NewMenu creates a menu controller. Call it once, alongside your signals.
func NewMenu(o MenuOptions) *Menu {
if o.HoverCloseDelay <= 0 {
o.HoverCloseDelay = defaultHoverCloseDelay
}
if onToggle != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onToggle))
}
return vdom.El("div", kids(mods, children)...)
}
// MenuContent is the dropdown panel; it renders only when open, positioned with
// static Tailwind utilities for the given placement.
func MenuContent(open bool, placement MenuPlacement, class string, children ...*vdom.VNode) *vdom.VNode {
m := &Menu{hoverCloseDelay: o.HoverCloseDelay}
m.f = NewFloating(FloatingOptions{
Placement: pick(o.Placement, PlacementBottomStart),
Offset: o.Offset,
Standalone: o.Standalone,
OpenOnHover: o.OpenOnHover,
HoverDelay: o.HoverDelay,
HoverCloseDelay: o.HoverCloseDelay,
// A long menu near the bottom of the screen scrolls inside its own box
// rather than running off it.
ConstrainToViewport: true,
OnOpenChange: func(open bool) {
// A submenu is Standalone (see NewSubmenu), so the single-open manager
// will not close it for us — its parent owns it and must. This also
// covers the indirect close: another menu opening, an outside click, or
// Escape all route through Hide, and so through here.
if !open {
return nil
m.closeSubs()
}
pos := menuPlacementClasses[placement]
if pos == "" {
pos = menuPlacementClasses[MenuPlacementBottomStart]
if o.OnOpenChange != nil {
o.OnOpenChange(open)
}
mods := []vdom.Mod{
vdom.Attr("class", cx("absolute z-50", pos, menuCls, class)),
vdom.Attr("role", "menu"),
}
return vdom.El("div", kids(mods, children)...)
},
})
return m
}
// MenuItemProps configures MenuItem.
// NewSubmenu creates a submenu owned by parent. Like NewMenu, call it once,
// outside the render function.
//
// The submenu's Floating is deliberately Standalone: the single-open manager would
// otherwise read "submenu opens" as "a rival panel opened" and close the very menu
// the submenu hangs off. Ownership is expressed structurally instead — the parent
// closes its submenus when it closes (see NewMenu's OnOpenChange), and the
// open-order stack still makes Escape close the submenu first and an outside-click
// on the submenu leave the parent alone.
//
// Positioning is right-start with no offset, which is what the TSX hand-rolled
// (Menu.tsx:252-287). It does NOT reimplement that math: ComputePosition flips to
// the left side when the right does not fit — and, unlike the TSX, only when the
// left side actually fits, instead of flipping unconditionally into an equally bad
// spot — clamps vertically, and reports the room available so a tall submenu can
// scroll (ConstrainToViewport). NewFloating turns Offset 0 into its 4px default;
// the resulting sliver of a gap is spanned by the hover bridge.
//
// A submenu always opens on hover (the TSX's Submenu did, whatever the parent's
// openOnHover said) and toggles on click, so it is reachable by touch and keyboard.
func NewSubmenu(parent *Menu) *Menu {
s := &Menu{parent: parent, hoverCloseDelay: parent.hoverCloseDelay}
s.f = NewFloating(FloatingOptions{
Placement: PlacementRightStart,
Offset: 0,
Standalone: true,
ConstrainToViewport: true,
OpenOnHover: true,
HoverCloseDelay: parent.hoverCloseDelay,
OnOpenChange: func(open bool) {
if !open {
s.closeSubs()
}
},
})
parent.subs = append(parent.subs, s)
return s
}
// ---- state ----
// IsOpen reports whether this menu's panel is open. Safe to read during render.
func (m *Menu) IsOpen() bool { return m.f.IsOpen() }
// Open shows the menu, Toggle flips it.
func (m *Menu) Open() { m.f.Show() }
func (m *Menu) Toggle() { m.f.Toggle() }
// Close dismisses this menu AND every menu above it. Clicking an item inside a
// submenu therefore tears down the whole stack, which is what the TSX did by
// handing every level the ROOT's closeMenu (the root's unmount took its nested
// submenus with it). Descendants come down too, via NewMenu's OnOpenChange.
func (m *Menu) Close() {
for cur := m; cur != nil; cur = cur.parent {
cur.f.Hide()
}
}
// Dispose closes this menu and its submenus and drops every listener. Call it if
// the component owning the menu goes away while it might still be open.
func (m *Menu) Dispose() {
for _, s := range m.subs {
s.Dispose()
}
m.f.Dispose()
}
func (m *Menu) closeSubs() {
for _, s := range m.subs {
s.f.Hide() // cascades: each sub's own OnOpenChange closes ITS subs
}
}
// ---- the parent<->child hover chain ----
//
// Floating's hover bridge spans a trigger and ITS OWN panel. It cannot span a
// parent menu and a child submenu, because the submenu's panel is portaled to
// document.body: visually the submenu sits flush against the menu that spawned it,
// but in the DOM it is nowhere near it, so crossing from one to the other fires the
// parent's mouseleave and starts its close timer. (In the TSX the submenu panel was
// an in-flow child of the parent panel, so DOM containment gave this away for free —
// at the cost of being clipped by the parent's overflow-y-auto, which is the bug we
// are fixing.) These two restore the containment the DOM no longer expresses: this
// is the TSX's cancelParentClose, generalized up the whole chain.
// cancelAncestorClose stops every ancestor's pending hover-close: the cursor is on
// this submenu, which counts as being on all of them.
func (m *Menu) cancelAncestorClose() {
for cur := m.parent; cur != nil; cur = cur.parent {
cur.f.cancelHover()
}
}
// scheduleAncestorClose re-arms them when the cursor leaves this submenu's panel —
// leaving a submenu means leaving everything it hangs off. Only hover-managed
// ancestors are re-armed; a click-opened root menu stays open until it is clicked
// away, exactly as in the TSX (where MenuContent only wired hover handlers when
// openOnHover was set).
func (m *Menu) scheduleAncestorClose() {
for cur := m.parent; cur != nil; cur = cur.parent {
if cur.f.opts.OpenOnHover {
cur.f.hoverLeave()
}
}
}
// ---- rendering ----
// MenuTriggerProps configures the element that opens the menu.
type MenuTriggerProps struct {
Class string
Title string
// Tag is the trigger element; default "button" (what the TSX rendered). Use
// "div" or "span" when the trigger's own content is a <button> — nesting
// buttons is invalid HTML and the inner one swallows the click.
Tag string
}
// Trigger renders the element that toggles the menu. It also opens on hover when
// MenuOptions.OpenOnHover is set, and closes on Escape.
func (m *Menu) Trigger(p MenuTriggerProps, children ...*vdom.VNode) *vdom.VNode {
return m.f.Trigger(FloatingTriggerProps{Class: p.Class, Title: p.Title, Tag: p.Tag}, children...)
}
// TriggerFunc is Trigger with the TSX's render-prop children — `(state: {isOpen}) => JSX`,
// which callers use to flip a chevron with the menu's state. Go has no JSX callback
// convention, so the open state is passed as a plain bool:
//
// menu.TriggerFunc(webui.MenuTriggerProps{}, func(open bool) *vdom.VNode {
// if open { return webui.Icon("chevron-up", 16, "") }
// return webui.Icon("chevron-down", 16, "")
// })
func (m *Menu) TriggerFunc(p MenuTriggerProps, render func(open bool) *vdom.VNode) *vdom.VNode {
if render == nil {
return m.Trigger(p)
}
return m.Trigger(p, render(m.f.IsOpen()))
}
// Content renders the dropdown panel: portaled to document.body, measured, and
// revealed only once it has been positioned. It renders nothing while closed.
func (m *Menu) Content(class string, children ...*vdom.VNode) *vdom.VNode {
return m.f.Panel(FloatingPanelProps{Class: cx(menuCls, class)}, children...)
}
// MenuItemProps configures Menu.Item.
type MenuItemProps struct {
Icon string
Disabled bool
OnClick func()
// KeepOpen leaves the menu open after the click. This is the TSX's closeOnClick,
// inverted: closeOnClick defaulted to TRUE, and Go props have no "unset", so the
// negative form is what preserves the default in the zero value.
KeepOpen bool
Class string
}
// MenuItem is a <button> menu entry.
func MenuItem(p MenuItemProps, children ...*vdom.VNode) *vdom.VNode {
// Item is a <button> menu entry. It closes the menu (and, from inside a submenu,
// the whole stack) after running OnClick, unless KeepOpen is set.
func (m *Menu) Item(p MenuItemProps, children ...*vdom.VNode) *vdom.VNode {
activate := func() {
if p.Disabled {
return
}
if p.OnClick != nil {
p.OnClick()
}
if !p.KeepOpen {
m.Close()
}
}
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("role", "menuitem"),
vdom.Attr("class", cx(menuItemCls, p.Class)),
vdom.On(vdom.EVENT_CLICK, activate),
// A <button> already synthesizes a click from Enter/Space, but the TSX handled
// the keys explicitly and called preventDefault — which also stops Space from
// scrolling the page behind the menu. Ported as-is; preventDefault suppresses
// the synthesized click, so the item fires exactly once.
vdom.OnEvent(vdom.EVENT_KEYDOWN, func(ev vdom.Event) {
switch ev.Key() {
case vdom.KEY_ENTER, vdom.KEY_SPACE:
ev.PreventDefault()
activate()
}
}),
}
if p.Disabled {
mods = append(mods, vdom.Attr("disabled", "disabled"))
} else if p.OnClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
}
if p.Icon != "" {
mods = append(mods, Icon(p.Icon, 16, "shrink-0"))
}
return vdom.El("button", kids(mods, children)...)
return vdom.Button(kids(mods, children)...)
}
// MenuLink is an anchor menu entry for internal navigation.
func MenuLink(href, icon, class string, children ...*vdom.VNode) *vdom.VNode {
// Link is an anchor menu entry for internal navigation. It closes the menu on
// click, like the TSX's MenuLink.
func (m *Menu) Link(href, icon, class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("href", href),
vdom.Attr("role", "menuitem"),
vdom.Attr("class", cx(menuItemCls, class)),
vdom.On(vdom.EVENT_CLICK, m.Close),
}
if icon != "" {
mods = append(mods, Icon(icon, 16, "shrink-0"))
}
return vdom.El("a", kids(mods, children)...)
return vdom.A(kids(mods, children)...)
}
// MenuAnchorProps configures MenuAnchor.
// MenuAnchorProps configures Menu.Anchor.
type MenuAnchorProps struct {
Href string
Icon string
@@ -133,17 +357,18 @@ type MenuAnchorProps struct {
Class string
}
// MenuAnchor is an anchor menu entry to an external URL (Target defaults to
// _blank, Rel to noopener noreferrer). A _blank target appends a trailing arrow.
func MenuAnchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
// Anchor is an anchor menu entry to an external URL (Target defaults to _blank, Rel
// to noopener noreferrer). A _blank target appends a trailing arrow. It closes the
// menu on click.
func (m *Menu) Anchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
target := pick(p.Target, "_blank")
rel := pick(p.Rel, "noopener noreferrer")
mods := []vdom.Mod{
vdom.Attr("href", p.Href),
vdom.Attr("target", target),
vdom.Attr("rel", rel),
vdom.Attr("rel", pick(p.Rel, "noopener noreferrer")),
vdom.Attr("role", "menuitem"),
vdom.Attr("class", cx(menuItemCls, p.Class)),
vdom.On(vdom.EVENT_CLICK, m.Close),
}
if p.Icon != "" {
mods = append(mods, Icon(p.Icon, 16, "shrink-0"))
@@ -152,70 +377,109 @@ func MenuAnchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
if target == "_blank" {
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-neutral-400"))
}
return vdom.El("a", mods...)
return vdom.A(mods...)
}
// MenuDivider is a horizontal separator between groups of items.
func MenuDivider(class string) *vdom.VNode {
return vdom.El("hr", vdom.Attr("class", cx(menuDividerCls, class)), vdom.Attr("role", "separator"))
}
// MenuSection is an uppercase section label.
func MenuSection(class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", cx(menuSectionCls, class)), vdom.Attr("role", "presentation")}
return vdom.El("div", kids(mods, children)...)
}
// MenuGroup groups related items together (role=group).
func MenuGroup(class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("role", "group"), vdom.Attr("class", class)}
return vdom.El("div", kids(mods, children)...)
}
// SubmenuProps configures Submenu.
// SubmenuProps configures the submenu's trigger row.
type SubmenuProps struct {
Open bool
OnToggle func()
Trigger string
Icon string
Class string
}
// Submenu is a nested menu opened from a parent item. When Open, its panel
// renders to the right of the trigger via static Tailwind (no measurement).
func Submenu(p SubmenuProps, children ...*vdom.VNode) *vdom.VNode {
ariaExpanded := "false"
if p.Open {
ariaExpanded = "true"
}
// Submenu renders a submenu created with NewSubmenu: the trigger row that sits
// among the parent's items, plus the submenu's own portaled panel. Call it on the
// SUBMENU controller and give it the submenu's items as children, so those items
// close through the submenu (and so up the whole chain):
//
// sub.Submenu(webui.SubmenuProps{Trigger: "Export", Icon: "download"},
// sub.Item(webui.MenuItemProps{OnClick: exportCSV}, vdom.Text("CSV")),
// )
//
// The two halves are wrapped in a bare <div> only because a Go component returns a
// single VNode; the wrapper is layout-neutral (the panel is portaled out of it and
// the trigger row is w-full either way).
func (m *Menu) Submenu(p SubmenuProps, children ...*vdom.VNode) *vdom.VNode {
label := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
if p.Icon != "" {
label = append(label, Icon(p.Icon, 16, "shrink-0"))
}
label = append(label, vdom.Text(p.Trigger))
triggerMods := []vdom.Mod{
vdom.Attr("role", "menuitem"),
vdom.Attr("aria-haspopup", "menu"),
vdom.Attr("aria-expanded", ariaExpanded),
vdom.Attr("class", cx(menuSubmenuTriggerCls, p.Class)),
}
if p.OnToggle != nil {
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
}
triggerMods = append(triggerMods,
vdom.El("span", label...),
trigger := m.f.Trigger(FloatingTriggerProps{
Tag: "div", // a row inside a menu, not a nested <button>
Class: cx(menuSubmenuTriggerCls, p.Class),
// Hover already opened it; the click is for touch and for closing it again.
OnClick: m.f.Toggle,
},
vdom.Span(label...),
Icon("chevron-right", 16, "shrink-0 ml-auto text-neutral-400"),
)
menuSetAttr(trigger, "role", "menuitem")
// Entering the trigger means entering the submenu; it must not let an ancestor
// close underneath it. Its mouseLEAVE deliberately does NOT re-arm the ancestors:
// the cursor is still inside the parent's panel (it just moved to another item),
// and the parent's panel gets no fresh mouseenter to cancel a close we scheduled.
menuChain(trigger, vdom.EVENT_MOUSEENTER, m.cancelAncestorClose)
wrap := []vdom.Mod{vdom.Attr("class", "relative"), vdom.El("div", triggerMods...)}
if p.Open {
contentMods := []vdom.Mod{
vdom.Attr("role", "menu"),
vdom.Attr("class", cx("absolute left-full top-0 ml-1 z-[51]", menuCls)),
panel := m.f.Panel(FloatingPanelProps{Class: menuCls}, children...)
if el := menuPanelEl(panel); el != nil {
menuChain(el, vdom.EVENT_MOUSEENTER, m.cancelAncestorClose)
menuChain(el, vdom.EVENT_MOUSELEAVE, m.scheduleAncestorClose)
}
wrap = append(wrap, vdom.El("div", kids(contentMods, children)...))
return vdom.Div(trigger, panel)
}
return vdom.El("div", wrap...)
// MenuDivider is a horizontal separator between groups of items.
func MenuDivider(class string) *vdom.VNode {
return vdom.Hr(vdom.Attr("class", cx(menuDividerCls, class)), vdom.Attr("role", "separator"))
}
// MenuSection is an uppercase section label.
func MenuSection(class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", cx(menuSectionCls, class)), vdom.Attr("role", "presentation")}
return vdom.Div(kids(mods, children)...)
}
// MenuGroup groups related items together (role=group).
func MenuGroup(class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("role", "group"), vdom.Attr("class", class)}
return vdom.Div(kids(mods, children)...)
}
// ---- small helpers over the nodes Floating builds ----
// menuChain adds fn to a node's handler for event instead of replacing it: the
// submenu's parent-chaining behavior has to run ON TOP of the hover bridge Floating
// already wired into its trigger and panel.
func menuChain(n *vdom.VNode, event string, fn func()) {
if n == nil {
return
}
prev := n.Events[event]
n.Events[event] = func(ev vdom.Event) {
if prev != nil {
prev(ev)
}
fn()
}
}
// menuSetAttr sets an attribute on an already-built node (FloatingTriggerProps has
// no Role field, and a submenu's trigger row is a menuitem).
func menuSetAttr(n *vdom.VNode, k, v string) {
if n != nil {
n.Attrs[k] = v
}
}
// menuPanelEl digs the panel element out of what Floating.Panel returns — a Portal
// wrapping exactly one div — and is nil while the panel is closed and the portal is
// empty.
func menuPanelEl(portal *vdom.VNode) *vdom.VNode {
if portal == nil || len(portal.Children) != 1 {
return nil
}
return portal.Children[0]
}

View File

@@ -4,28 +4,68 @@ import (
"strconv"
"kjol/vdom"
"kjol/wasmruntime"
)
// Port of web/kit/Modal.tsx.
// Port of web/uikit/Modal.tsx.
//
// NOTE: Solid's Portal (render to document.body) is dropped — modals render
// inline where they are placed. Callers should mount them near the page root so
// the fixed-position container is not clipped by an ancestor's overflow/transform.
// NOTE: the entrance/exit animations (the isVisible signal plus the inline
// opacity/scale transition styles) are dropped; the modal renders in its final
// visible state.
// NOTE: the imperative context (ModalProvider / useModal / openModal(content))
// and the Escape-key handling (useModalEscape + the shared open-modal stack,
// which needs a document keydown listener) are dropped — there is no context or
// document access here. Use Modal with an IsOpen bool + OnClose callback instead.
// NOTE: WizardStepContext (setCanContinue/nextStep/prevStep passed into each
// step) is dropped. A WizardStep now carries a static Content node; the caller
// drives CurrentStep/CanContinue via props. The openVersion reset-on-open memo
// is likewise unnecessary here.
// NOTE: the undefined-vs-null header/footer distinction collapses to nil — a nil
// Header renders the close-only header (the undefined default); there is no
// "explicitly no header" case. The "circle-exclamation" wizard-error icon is not
// in the default registry and renders as an empty box until an app registers it.
// A modal is a *controller*, not a pure function: it owns the open flag, its slot
// on the shared modal stack (so Escape closes one layer per press), the refs it
// animates, and the document listener it installs. Create one per modal, ONCE,
// alongside your signals — never inside a render function, which would rebuild it
// (and its refs) every frame:
//
// edit := webui.NewModal(webui.ModalOptions{Size: webui.ModalMedium})
// return func() *vdom.VNode {
// return Div(
// webui.Button(webui.ButtonProps{Text: "Edit", OnClick: edit.Open}),
// edit.Render(webui.ModalProps{Header: Text("Edit user")}, form...),
// )
// }
//
// What the dialog does and why:
//
// 1. It renders PORTALED to document.body, so a modal opened from inside another
// modal's body is not clipped by the parent's `overflow: hidden` and does not
// have its `position: fixed` re-rooted by an ancestor `transform`.
// 2. Escape closes only the TOP modal (see the modal stack below), so nested
// modals unwind one layer per press.
// 3. Opening runs the enter animation imperatively: the panel is rendered at
// opacity 0 / scale(.95), then — after a DOUBLE requestAnimationFrame, which is
// what makes the browser commit that initial style before the target lands —
// SetStyle writes opacity 1 / scale(1) and the declared CSS transition
// interpolates. Writing this through a signal would re-render the entire app on
// every animation step.
//
// Faithful to the TSX (deliberately): the container is `<dialog open>` — the
// ATTRIBUTE, not .showModal() — so there is no native top layer, no ::backdrop and
// no native focus trap, and the original did no focus management of its own. None
// is invented here.
type Modal struct {
opts ModalOptions
open *vdom.Signal[bool]
panelRef *vdom.Ref
backdropRef *vdom.Ref
unsubs []wasmruntime.Unsub
}
// ModalOptions configures a Modal. The zero value is usable: default size, aligned
// to the top of the viewport, closes on Escape and on a backdrop click.
type ModalOptions struct {
Size ModalSize
CenterOnScreen bool
// OnClose fires after the modal closes, however it was dismissed (backdrop, the
// ✕ button, Escape, or a Close() call). The controller owns the open flag, so
// this is a notification — you do not have to mirror it in your own signal.
OnClose func()
KeepOnEscape bool // inverted: by default Escape closes the topmost modal
KeepOnBackdrop bool // inverted: by default a backdrop click closes
}
// ModalSize selects the modal panel's max width.
type ModalSize string
@@ -43,6 +83,14 @@ const (
ModalFull ModalSize = "full"
)
// The enter animation (ANIMATION_DURATION = 100ms in the TSX). The initial styles
// are DECLARED on the element — so the reconciler emits an identical style attribute
// on every render and never clobbers the imperative writes (see updateAttrs, which
// only calls setAttribute when the declared value changes) — and the target styles
// are written imperatively after a double rAF.
const modalPanelStyle = "opacity:0;transform:scale(0.95);transition:opacity 100ms cubic-bezier(0.4, 0, 0.2, 1), transform 100ms cubic-bezier(0.4, 0, 0.2, 1)"
const modalBackdropStyle = "opacity:0;transition:opacity 100ms ease-out"
// -- Tailwind class constants --------------------------------------
const modalContainerBase = "fixed inset-0 z-[100] w-full h-dvh m-0 border-0 bg-transparent flex justify-center max-w-screen max-h-dvh"
const modalContainerTop = "items-start pt-10"
@@ -106,27 +154,159 @@ const modalWizardBtnBack = "bg-transparent border border-neutral-300 text-neutra
const modalWizardBtnNext = "bg-neutral-800 text-white enabled:hover:bg-neutral-900"
const modalWizardBtnFinish = "bg-primary text-white enabled:hover:bg-red-700"
// modalCloseButton is the shared "✕" button (dismisses via onClose).
func modalCloseButton(onClose func(), size int) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", modalCloseBtn)}
if onClose != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClose))
// NewModal creates a modal controller. Call it once, outside your render function.
func NewModal(o ModalOptions) *Modal {
return &Modal{
opts: o,
open: vdom.NewSignal(false),
panelRef: vdom.NewRef(),
backdropRef: vdom.NewRef(),
}
mods = append(mods, Icon("xmark", size, ""))
return vdom.El("button", mods...)
}
func modalCloseOnlyHeader(onClose func()) *vdom.VNode {
return vdom.El("div", vdom.Attr("class", modalHeaderCloseOnly), modalCloseButton(onClose, 24))
// IsOpen reports the current state. Safe to read during render.
func (m *Modal) IsOpen() bool { return m.open.Get() }
// Open, Close and Toggle drive the dialog. Safe to call from event handlers.
func (m *Modal) Open() {
if m.open.Get() {
return
}
modalStackPush(m)
if !m.opts.KeepOnEscape {
m.unsubs = append(m.unsubs, wasmruntime.OnDocument(vdom.EVENT_KEYDOWN, false, m.onKeydown))
}
m.open.Set(true)
// The dialog does not exist yet — the signal write only *scheduled* a render.
// Animate it in once it does.
wasmruntime.AfterRender(m.mounted)
}
func modalFullHeader(header *vdom.VNode, onClose func()) *vdom.VNode {
return vdom.El("div", vdom.Attr("class", modalHeader), header, modalCloseButton(onClose, 24))
func (m *Modal) Close() {
if !m.open.Get() {
return
}
m.teardown()
modalStackRemove(m)
m.open.Set(false)
if m.opts.OnClose != nil {
m.opts.OnClose()
}
}
// modalDisplay is the dialog + backdrop + panel wrapper. Clicking the backdrop
// invokes onClose.
func modalDisplay(size ModalSize, centerOnScreen bool, onClose func(), children ...*vdom.VNode) *vdom.VNode {
func (m *Modal) Toggle() {
if m.open.Get() {
m.Close()
} else {
m.Open()
}
}
// Dispose closes the modal and removes every listener. Call it if the component
// owning this Modal goes away while the dialog might still be open.
func (m *Modal) Dispose() {
m.teardown()
modalStackRemove(m)
if m.open.Get() {
m.open.Set(false)
}
}
func (m *Modal) teardown() {
for _, un := range m.unsubs {
un()
}
m.unsubs = nil
}
// mounted runs once the dialog is in the DOM: play the enter animation.
//
// The double rAF is REQUIRED and not superstition: the target styles have to land
// in a LATER frame than the initial ones, or the browser never commits an initial
// value to interpolate from and the element simply appears. One frame is not
// enough (the render commit and the first rAF can share a frame).
func (m *Modal) mounted() {
// Re-assert the initial state before animating. Normally redundant (the declared
// style attribute already says this), but a re-opened modal can land on a DOM node
// the reconciler reused, which would still carry the imperative end state.
wasmruntime.SetStyle(m.backdropRef, "opacity", "0")
wasmruntime.SetStyle(m.panelRef, "opacity", "0")
wasmruntime.SetStyle(m.panelRef, "transform", "scale(0.95)")
wasmruntime.RAF(func() { wasmruntime.RAF(m.reveal) })
}
func (m *Modal) reveal() {
if !m.open.Get() {
return
}
wasmruntime.SetStyle(m.backdropRef, "opacity", "1")
wasmruntime.SetStyle(m.panelRef, "opacity", "1")
wasmruntime.SetStyle(m.panelRef, "transform", "scale(1)")
}
// onKeydown closes on Escape — but only the topmost modal, so a modal opened from
// inside another closes one layer per press instead of collapsing the whole stack.
func (m *Modal) onKeydown(ev vdom.Event) {
if ev.Key() != vdom.KEY_ESCAPE || !modalIsTopmost(m) {
return
}
ev.PreventDefault()
m.Close()
}
// ---- the modal stack ----
//
// The Go equivalent of the TSX's openModalStack: every open modal, in open order.
// Order is the whole point — the document keydown handler is installed once per open
// modal, and each one closes only if it is on top.
var modalStack []*Modal
func modalStackPush(m *Modal) { modalStack = append(modalStack, m) }
func modalStackRemove(m *Modal) {
for i, o := range modalStack {
if o == m {
modalStack = append(modalStack[:i], modalStack[i+1:]...)
return
}
}
}
func modalIsTopmost(m *Modal) bool {
return len(modalStack) > 0 && modalStack[len(modalStack)-1] == m
}
// ---- rendering ----
// ModalProps configures one render of a Modal. A nil Header renders the close-only
// header (the TSX's `undefined` default); a nil Footer renders the spacer. Size and
// alignment live on ModalOptions, because they belong to the modal, not to a frame
// of it.
type ModalProps struct {
Header *vdom.VNode
Footer *vdom.VNode
}
// Render draws the dialog when open, portaled to document.body.
//
// When closed it renders an EMPTY portal rather than nothing, so its slot in the
// parent's child list never disappears — the reconciler diffs children by index, and
// a vanishing child would shift every sibling after it.
func (m *Modal) Render(p ModalProps, children ...*vdom.VNode) *vdom.VNode {
if !m.open.Get() {
return vdom.Portal()
}
nodes := modalContentNodes(p.Header, p.Footer, m.Close, children)
return vdom.Portal(m.display(m.opts.Size, m.opts.CenterOnScreen, nodes...))
}
// display is the TSX's ModalDisplay: the `<dialog open>` container, the backdrop
// (clicking it closes), and the panel. Both animated elements carry a CONSTANT
// declared style — the initial state — which the enter animation then overwrites
// imperatively.
func (m *Modal) display(size ModalSize, centerOnScreen bool, children ...*vdom.VNode) *vdom.VNode {
align := modalContainerTop
if centerOnScreen {
align = modalContainerCenter
@@ -135,23 +315,48 @@ func modalDisplay(size ModalSize, centerOnScreen bool, onClose func(), children
size = ModalDefault
}
backdrop := []vdom.Mod{vdom.Attr("class", modalBackdrop)}
if onClose != nil {
backdrop = append(backdrop, vdom.On(vdom.EVENT_CLICK, onClose))
backdrop := []vdom.Mod{
vdom.WithRef(m.backdropRef),
vdom.Attr("class", modalBackdrop),
vdom.Attr("style", modalBackdropStyle),
}
if !m.opts.KeepOnBackdrop {
backdrop = append(backdrop, vdom.On(vdom.EVENT_CLICK, m.Close))
}
panel := kids([]vdom.Mod{vdom.Attr("class", cx(modalBase, modalSizes[size]))}, children)
panel := kids([]vdom.Mod{
vdom.WithRef(m.panelRef),
vdom.Attr("class", cx(modalBase, modalSizes[size])),
vdom.Attr("style", modalPanelStyle),
}, children)
return vdom.El("dialog",
vdom.Attr("open", "open"),
return vdom.Dialog(vdom.Attr("open", "open"),
vdom.Attr("class", cx(modalContainerBase, align)),
vdom.El("div", backdrop...),
vdom.El("div", panel...),
vdom.Div(backdrop...),
vdom.Div(panel...),
)
}
// modalCloseButton is the shared "✕" button (dismisses via onClose).
func modalCloseButton(onClose func(), size int) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", modalCloseBtn)}
if onClose != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClose))
}
mods = append(mods, Icon("xmark", size, ""))
return vdom.Button(mods...)
}
func modalCloseOnlyHeader(onClose func()) *vdom.VNode {
return vdom.Div(vdom.Attr("class", modalHeaderCloseOnly), modalCloseButton(onClose, 24))
}
func modalFullHeader(header *vdom.VNode, onClose func()) *vdom.VNode {
return vdom.Div(vdom.Attr("class", modalHeader), header, modalCloseButton(onClose, 24))
}
// modalContentNodes builds the header / body / footer panel children shared by
// Modal and ModalContent. A nil header renders the close-only header; a nil
// Modal.Render and ModalContent. A nil header renders the close-only header; a nil
// footer renders the spacer.
func modalContentNodes(header, footer *vdom.VNode, onClose func(), children []*vdom.VNode) []*vdom.VNode {
var nodes []*vdom.VNode
@@ -160,56 +365,102 @@ func modalContentNodes(header, footer *vdom.VNode, onClose func(), children []*v
} else {
nodes = append(nodes, modalFullHeader(header, onClose))
}
nodes = append(nodes, vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", modalBody)}, children)...))
nodes = append(nodes, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", modalBody)}, children)...))
if footer == nil {
nodes = append(nodes, vdom.El("div", vdom.Attr("class", modalFooterSpacer)))
nodes = append(nodes, vdom.Div(vdom.Attr("class", modalFooterSpacer)))
} else {
nodes = append(nodes, vdom.El("div", vdom.Attr("class", modalFooter), footer))
nodes = append(nodes, vdom.Div(vdom.Attr("class", modalFooter), footer))
}
return nodes
}
// ModalProps configures Modal. A nil Header renders a close-only header; a nil
// Footer renders a spacer.
type ModalProps struct {
IsOpen bool
OnClose func()
Size ModalSize
CenterOnScreen bool
Header *vdom.VNode
Footer *vdom.VNode
// ---- the imperative opener (the TSX's ModalProvider / useModal / openModal) ----
//
// Go has no context, so "open a modal from anywhere" is a package-level store plus a
// host component the app renders ONCE, near the root of its tree:
//
// func App() *vdom.VNode {
// return Div(page(), webui.ModalHost())
// }
//
// and from anywhere at all:
//
// webui.OpenModal(func() *vdom.VNode {
// return webui.ModalContent(webui.ModalContentProps{Header: Text("Details")}, body...)
// }, webui.ModalOptions{Size: webui.ModalMedium})
//
// The content is a BUILDER, not a VNode: it is invoked on every render, so content
// that reads signals stays live — which is what the TSX got for free by storing an
// already-reactive JSXElement.
var (
globalModal = NewModal(ModalOptions{})
globalContent func() *vdom.VNode
// globalVersion exists only to force a re-render when the content is swapped
// while the modal is already open (the open signal would not change).
globalVersion = vdom.NewSignal(0)
)
// OpenModal opens the shared modal with the given content and options. Rendered by
// ModalHost. Calling it while a modal is already open swaps the content in place, as
// the TSX did (no second enter animation).
func OpenModal(content func() *vdom.VNode, o ModalOptions) {
user := o.OnClose
o.OnClose = func() {
globalContent = nil
if user != nil {
user()
}
}
globalModal.opts = o
globalContent = content
globalVersion.Update(func(v int) int { return v + 1 })
globalModal.Open()
}
// Modal renders a dialog when IsOpen; otherwise it renders nothing (nil).
func Modal(p ModalProps, children ...*vdom.VNode) *vdom.VNode {
if !p.IsOpen {
return nil
// CloseModal closes the shared modal (the TSX's useModal().closeModal).
func CloseModal() { globalModal.Close() }
// ModalIsOpen reports whether the shared modal is open.
func ModalIsOpen() bool { return globalModal.IsOpen() }
// ModalHost renders the shared modal. Render it once, near the root of the app.
// Nothing (an empty portal) when no modal is open.
func ModalHost() *vdom.VNode {
_ = globalVersion.Get() // subscribe: a content swap must repaint the host
if !globalModal.IsOpen() || globalContent == nil {
return vdom.Portal()
}
nodes := modalContentNodes(p.Header, p.Footer, p.OnClose, children)
return modalDisplay(p.Size, p.CenterOnScreen, p.OnClose, nodes...)
return vdom.Portal(globalModal.display(globalModal.opts.Size, globalModal.opts.CenterOnScreen, globalContent()))
}
// ModalContentProps configures ModalContent.
type ModalContentProps struct {
Header *vdom.VNode
Footer *vdom.VNode
// OnClose overrides the default dismiss action (CloseModal, i.e. close the
// shared modal this content was opened into).
OnClose func()
}
// ModalContent renders the header / body / footer trio to drop inside a
// modalDisplay panel. The TSX returned a fragment; here it is wrapped in a
// display:contents div so it adds no layout box.
// ModalContent renders the header / body / footer trio for content passed to
// OpenModal — the panel chrome that ModalHost's bare panel does not impose. The TSX
// returned a fragment; here it is wrapped in a display:contents div so it adds no
// layout box.
func ModalContent(p ModalContentProps, children ...*vdom.VNode) *vdom.VNode {
nodes := modalContentNodes(p.Header, p.Footer, p.OnClose, children)
mods := kids([]vdom.Mod{vdom.Attr("class", "contents")}, nodes)
return vdom.El("div", mods...)
onClose := p.OnClose
if onClose == nil {
onClose = CloseModal
}
nodes := modalContentNodes(p.Header, p.Footer, onClose, children)
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "contents")}, nodes)...)
}
// ConfirmModalProps configures ConfirmModal. ConfirmStyle is "danger" (default)
// or "primary".
// ---- confirm ----
// ConfirmModalProps configures Modal.Confirm. ConfirmStyle is "danger" (default) or
// "primary".
type ConfirmModalProps struct {
IsOpen bool
OnClose func()
OnConfirm func()
Title string
Message string
@@ -218,76 +469,154 @@ type ConfirmModalProps struct {
ConfirmStyle string
}
// ConfirmModal is a small centered modal with cancel/confirm buttons.
func ConfirmModal(p ConfirmModalProps) *vdom.VNode {
if !p.IsOpen {
return nil
// Confirm renders this modal as a small, centred confirm dialog (as the TSX's
// ConfirmModal did — it hard-coded MODAL_SMALL + centerOnScreen). Confirming runs
// OnConfirm and then closes.
//
// del := webui.NewModal(webui.ModalOptions{})
// …
// del.Confirm(webui.ConfirmModalProps{Message: "Delete this row?", OnConfirm: doDelete})
func (m *Modal) Confirm(p ConfirmModalProps) *vdom.VNode {
if !m.open.Get() {
return vdom.Portal()
}
title := pick(p.Title, "Confirm")
confirmText := pick(p.ConfirmText, "Confirm")
cancelText := pick(p.CancelText, "Cancel")
style := pick(p.ConfirmStyle, "danger")
okVariant := modalConfirmOkVariants[style]
okVariant := modalConfirmOkVariants[pick(p.ConfirmStyle, "danger")]
if okVariant == "" {
okVariant = modalConfirmOkVariants["danger"]
}
cancelMods := []vdom.Mod{vdom.Attr("class", modalConfirmCancel)}
if p.OnClose != nil {
cancelMods = append(cancelMods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
}
cancelMods = append(cancelMods, vdom.Text(cancelText))
okMods := []vdom.Mod{
cancel := vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", modalConfirmCancel),
vdom.On(vdom.EVENT_CLICK, m.Close),
vdom.Text(cancelText),
)
ok := vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", cx(modalConfirmOkBase, okVariant)),
vdom.On(vdom.EVENT_CLICK, func() {
if p.OnConfirm != nil {
p.OnConfirm()
}
if p.OnClose != nil {
p.OnClose()
}
m.Close()
}),
vdom.Text(confirmText),
}
footer := vdom.El("div", vdom.Attr("class", modalConfirmWrap),
vdom.El("button", cancelMods...),
vdom.El("button", okMods...),
)
footer := vdom.Div(vdom.Attr("class", modalConfirmWrap), cancel, ok)
return Modal(ModalProps{
IsOpen: true,
OnClose: p.OnClose,
Size: ModalSmall,
CenterOnScreen: true,
Header: vdom.Text(title),
Footer: footer,
}, vdom.Text(p.Message))
size := m.opts.Size
if size == "" {
size = ModalSmall
}
nodes := modalContentNodes(vdom.Text(title), footer, m.Close, []*vdom.VNode{vdom.Text(p.Message)})
return vdom.Portal(m.display(size, true, nodes...))
}
// WizardStep is one step of a WizardModal. Content is rendered statically (the
// TSX's per-step callback context is dropped — see the file NOTE).
// ---- wizard ----
// WizardStepContext is what a step's content function is handed. Go has no context,
// so the TSX's WizardStepContext is threaded EXPLICITLY through the step render
// function instead — that is the whole shape of it:
//
// {Title: "Account", Content: func(ctx webui.WizardStepContext) *vdom.VNode {
// ctx.SetCanContinue(email.Get() != "") // gates this step's Next button
// return webui.TextInput(…)
// }}
//
// SetCanContinue is PER STEP (it writes the flag for ctx.Index), which is what the
// single caller-owned CanContinue bool could not express. It is safe to call during
// render: a write that does not change the flag does not schedule a render, so a
// step that asserts its own completeness every frame cannot loop.
type WizardStepContext struct {
// Index is this step's position in Steps.
Index int
// Version increments every time the wizard is opened. It is the Go stand-in for
// the TSX's openVersion memo, which re-instantiated each step's content on every
// open: Go step content is a plain function with no instance state to reset, so
// key any per-open state you keep yourself off this value.
Version int
SetCanContinue func(bool)
NextStep func()
PrevStep func()
}
// WizardStep is one step of a Wizard. Content is called on every render with this
// step's context.
type WizardStep struct {
Title string
Content *vdom.VNode
Content func(WizardStepContext) *vdom.VNode
}
// WizardModalProps configures WizardModal. CurrentStep + OnStepChange drive
// navigation; CanContinue gates the Next/Finish button for the current step.
type WizardModalProps struct {
IsOpen bool
OnClose func()
OnComplete func()
// Wizard is a multi-step modal: a progress header, one step's body at a time, and a
// Back / Next(Finish) footer. Like Modal it is a controller — create it once, outside
// render.
//
// It owns the step index and the per-step "can continue" flags; opening resets both
// (and bumps the version), which is the TSX's on-open reset effect.
type Wizard struct {
modal *Modal
step *vdom.Signal[int]
canContinue *vdom.Signal[[]bool]
version int
}
// WizardProps configures one render of a Wizard.
type WizardProps struct {
Steps []WizardStep
CurrentStep int
OnStepChange func(int)
CanContinue bool
Size ModalSize
CenterOnScreen bool
// Title overrides the header title; empty falls back to the current step's title.
Title string
FinishText string
// Error, when non-empty, renders the error strip above the footer.
Error string
OnComplete func()
}
// NewWizard creates a wizard controller. Size defaults to ModalLarge, as in the TSX.
func NewWizard(o ModalOptions) *Wizard {
if o.Size == "" {
o.Size = ModalLarge
}
return &Wizard{
modal: NewModal(o),
step: vdom.NewSignal(0),
canContinue: vdom.NewSignal[[]bool](nil),
}
}
// Open resets the wizard to step 0 with every step incomplete, bumps the version, and
// opens the dialog.
func (w *Wizard) Open() {
w.version++
w.step.Set(0)
w.canContinue.Set(nil)
w.modal.Open()
}
func (w *Wizard) Close() { w.modal.Close() }
func (w *Wizard) IsOpen() bool { return w.modal.IsOpen() }
func (w *Wizard) Dispose() { w.modal.Dispose() }
func (w *Wizard) Step() int { return w.step.Get() }
func (w *Wizard) Modal() *Modal { return w.modal }
// canContinueAt is the flag for step i (absent == not complete).
func (w *Wizard) canContinueAt(i int) bool {
flags := w.canContinue.Get()
return i >= 0 && i < len(flags) && flags[i]
}
// setCanContinue writes step i's flag. A no-op write is dropped, so content that
// calls ctx.SetCanContinue during render converges instead of re-rendering forever.
func (w *Wizard) setCanContinue(i int, v bool) {
if i < 0 || v == w.canContinueAt(i) {
return
}
flags := w.canContinue.Get()
next := make([]bool, max(len(flags), i+1))
copy(next, flags)
next[i] = v
w.canContinue.Set(next)
}
func modalStepIndicatorClass(i, cur int) string {
@@ -308,22 +637,29 @@ func modalWizardNextBtnClass(isLast bool) string {
return cx(modalWizardBtnBase, modalWizardBtnNext)
}
// WizardModal is a multi-step modal with a progress header and Back/Next footer.
func WizardModal(p WizardModalProps) *vdom.VNode {
if !p.IsOpen {
return nil
// Render draws the wizard when open (portaled, animated and Escape-closable like any
// other modal), and an empty portal when closed.
func (w *Wizard) Render(p WizardProps) *vdom.VNode {
if !w.modal.IsOpen() {
return vdom.Portal()
}
steps := p.Steps
total := len(steps)
cur := p.CurrentStep
size := p.Size
if size == "" {
size = ModalLarge
}
finishText := pick(p.FinishText, "Finish")
cur := clampIndex(w.step.Get(), total)
isFirst := cur == 0
isLast := cur == total-1
next := func() {
if cur < total-1 {
w.step.Set(cur + 1)
}
}
prev := func() {
if cur > 0 {
w.step.Set(cur - 1)
}
}
title := p.Title
currentStepTitle := ""
if cur >= 0 && cur < total {
@@ -342,8 +678,8 @@ func WizardModal(p WizardModalProps) *vdom.VNode {
// Progress track + step indicators.
stepsRow := []vdom.Mod{
vdom.Attr("class", modalWizardSteps),
vdom.El("div", vdom.Attr("class", modalWizardTrack),
vdom.El("div", vdom.Attr("class", modalWizardTrackFill), vdom.Attr("style", "width:"+pctStr+"%")),
vdom.Div(vdom.Attr("class", modalWizardTrack),
vdom.Div(vdom.Attr("class", modalWizardTrackFill), vdom.Attr("style", "width:"+pctStr+"%")),
),
}
for i := range steps {
@@ -351,23 +687,25 @@ func WizardModal(p WizardModalProps) *vdom.VNode {
if i < cur {
label = "✓"
}
stepsRow = append(stepsRow, vdom.El("div", vdom.Attr("class", modalWizardStepWrap),
vdom.El("div", vdom.Attr("class", modalStepIndicatorClass(i, cur)), vdom.Text(label)),
stepsRow = append(stepsRow, vdom.Div(vdom.Attr("class", modalWizardStepWrap),
vdom.Div(vdom.Attr("class", modalStepIndicatorClass(i, cur)), vdom.Text(label)),
))
}
titleRow := vdom.El("div", vdom.Attr("class", modalWizardTitleRow),
vdom.El("span", vdom.Attr("class", modalWizardTitle), vdom.Text(title)),
modalCloseButton(p.OnClose, 24),
titleRow := vdom.Div(vdom.Attr("class", modalWizardTitleRow),
vdom.Span(vdom.Attr("class", modalWizardTitle), vdom.Text(title)),
modalCloseButton(w.modal.Close, 24),
)
wizardHeader := vdom.El("div", vdom.Attr("class", modalWizardHeader),
wizardHeader := vdom.Div(vdom.Attr("class", modalWizardHeader),
titleRow,
vdom.El("div", vdom.Attr("class", modalWizardStepName), vdom.Text(currentStepTitle)),
vdom.El("div", stepsRow...),
vdom.Div(vdom.Attr("class", modalWizardStepName), vdom.Text(currentStepTitle)),
vdom.Div(stepsRow...),
)
headerDiv := vdom.El("div", vdom.Attr("class", modalHeader), wizardHeader)
headerDiv := vdom.Div(vdom.Attr("class", modalHeader), wizardHeader)
// Body: render every step, hiding the non-current ones.
// Body: every step is rendered (with its own context), and the non-current ones
// are hidden — so a step's DOM, and anything the browser owns in it (scroll
// offsets, an open <select>), survives navigating away and back.
bodyMods := []vdom.Mod{vdom.Attr("class", modalBody)}
for i, s := range steps {
itemMods := []vdom.Mod{}
@@ -375,58 +713,79 @@ func WizardModal(p WizardModalProps) *vdom.VNode {
itemMods = append(itemMods, vdom.Attr("style", "display:none"))
}
if s.Content != nil {
itemMods = append(itemMods, s.Content)
idx := i
content := s.Content(WizardStepContext{
Index: idx,
Version: w.version,
SetCanContinue: func(v bool) { w.setCanContinue(idx, v) },
NextStep: next,
PrevStep: prev,
})
if content != nil {
itemMods = append(itemMods, content)
}
bodyMods = append(bodyMods, vdom.El("div", itemMods...))
}
bodyMods = append(bodyMods, vdom.Div(itemMods...))
}
panel := []*vdom.VNode{headerDiv, vdom.El("div", bodyMods...)}
panel := []*vdom.VNode{headerDiv, vdom.Div(bodyMods...)}
if p.Error != "" {
panel = append(panel, vdom.El("div", vdom.Attr("class", modalWizardError),
panel = append(panel, vdom.Div(vdom.Attr("class", modalWizardError),
Icon("circle-exclamation", 16, modalWizardErrorIcon),
vdom.El("span", vdom.Text(p.Error)),
vdom.Span(vdom.Text(p.Error)),
))
}
// Footer: Back / Next(Finish).
backMods := []vdom.Mod{vdom.Attr("class", cx(modalWizardBtnBase, modalWizardBtnBack))}
backMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", cx(modalWizardBtnBase, modalWizardBtnBack)),
vdom.On(vdom.EVENT_CLICK, prev),
}
if isFirst {
backMods = append(backMods, vdom.Attr("disabled", "disabled"))
}
if p.OnStepChange != nil {
backMods = append(backMods, vdom.On(vdom.EVENT_CLICK, func() {
if !isFirst {
p.OnStepChange(cur - 1)
}
}))
}
backMods = append(backMods, vdom.Text("Back"))
nextMods := []vdom.Mod{vdom.Attr("class", modalWizardNextBtnClass(isLast))}
if !p.CanContinue {
nextMods = append(nextMods, vdom.Attr("disabled", "disabled"))
}
nextMods = append(nextMods, vdom.On(vdom.EVENT_CLICK, func() {
nextMods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", modalWizardNextBtnClass(isLast)),
vdom.On(vdom.EVENT_CLICK, func() {
if isLast {
if p.OnComplete != nil {
p.OnComplete()
}
} else if p.OnStepChange != nil {
p.OnStepChange(cur + 1)
return
}
next()
}),
}
if !w.canContinueAt(cur) {
nextMods = append(nextMods, vdom.Attr("disabled", "disabled"))
}
}))
nextLabel := "Next"
if isLast {
nextLabel = finishText
nextLabel = pick(p.FinishText, "Finish")
}
nextMods = append(nextMods, vdom.Text(nextLabel))
footerInner := vdom.El("div", vdom.Attr("class", modalWizardFooter),
vdom.El("button", backMods...),
vdom.El("button", nextMods...),
footerInner := vdom.Div(vdom.Attr("class", modalWizardFooter),
vdom.Button(backMods...),
vdom.Button(nextMods...),
)
panel = append(panel, vdom.El("div", vdom.Attr("class", modalFooter), footerInner))
panel = append(panel, vdom.Div(vdom.Attr("class", modalFooter), footerInner))
return modalDisplay(size, p.CenterOnScreen, p.OnClose, panel...)
return vdom.Portal(w.modal.display(w.modal.opts.Size, w.modal.opts.CenterOnScreen, panel...))
}
// clampIndex pins i into [0, n-1] (or 0 when there is nothing to index).
func clampIndex(i, n int) int {
if i < 0 || n <= 0 {
return 0
}
if i > n-1 {
return n - 1
}
return i
}

580
go/webui/pdf.go Normal file
View File

@@ -0,0 +1,580 @@
package webui
// A minimal PDF writer — exactly enough of the spec to paginate a table, and no
// more. Stdlib only (the gowasm engine takes no third-party dependency), which is
// also why the TSX's pdf-lib could not simply be swapped for a Go port of it.
//
// What is implemented:
//
// - Document structure: header, indirect objects, a classic cross-reference
// TABLE (not an xref stream), trailer, startxref, %%EOF.
// - Pages in portrait or landscape, US Letter (612x792 pt) — the same page size
// the TSX used (pdf-lib's default), so exports look identical.
// - Text in the two standard Type1 fonts that need no embedding: Helvetica and
// Helvetica-Bold, in WinAnsiEncoding.
// - Text measurement from the Adobe AFM glyph-width tables (below). Without
// widths you cannot size columns, truncate to fit, or right-align a number.
// - Filled rectangles and stroked lines (the grid, the zebra band, the rules).
//
// What is deliberately NOT implemented: images (so no logo — see
// autotable_export.go), compression (streams are plain, which makes the output
// greppable and the tests meaningful), transparency, annotations, outlines,
// metadata, encryption, and any font that needs embedding.
//
// Coordinates are PDF user space: origin bottom-left, y grows UP, units are
// points (1/72"). A caller lays a table out top-down by starting at
// height-margin and subtracting.
import (
"bytes"
"fmt"
"strconv"
"strings"
)
// PDFOrientation mirrors the TSX's PDFOrientation union (landscape is 0, and so
// is Go's zero value — landscape is the default for a table, as it was there).
type PDFOrientation int
const (
PDF_ORIENTATION_LANDSCAPE PDFOrientation = 0
PDF_ORIENTATION_PORTRAIT PDFOrientation = 1
)
// US Letter, in points. Portrait is 612x792; landscape swaps them.
const (
pdfLetterShort = 612.0
pdfLetterLong = 792.0
)
// PDFColor is an RGB fill/stroke color, each component in [0,1].
type PDFColor struct{ R, G, B float64 }
// PDFGray is the shade of gray at v (0 = black, 1 = white).
func PDFGray(v float64) PDFColor { return PDFColor{v, v, v} }
// PDFTextStyle is how a run of text is drawn.
type PDFTextStyle struct {
Size float64 // in points; 0 means 10
Bold bool // Helvetica-Bold instead of Helvetica
Color PDFColor
}
// PDFOptions configures a new document.
type PDFOptions struct {
Orientation PDFOrientation
// Width/Height override the page size in points. Zero means US Letter in the
// chosen orientation.
Width, Height float64
}
type pdfPage struct {
content bytes.Buffer
}
// PDF is a document being written. Draw onto the current page (the one AddPage
// last created); SetPage rewinds to an earlier one, which is how a footer like
// "Page 2 of 7" gets stamped onto pages that were finished before the total was
// known.
type PDF struct {
w, h float64
pages []*pdfPage
cur int
}
// NewPDF creates an empty document. It has no pages until AddPage is called;
// Bytes on a page-less document emits one blank page, because a PDF with zero
// pages is not a valid PDF.
func NewPDF(o PDFOptions) *PDF {
w, h := pdfLetterLong, pdfLetterShort // landscape
if o.Orientation == PDF_ORIENTATION_PORTRAIT {
w, h = pdfLetterShort, pdfLetterLong
}
if o.Width > 0 {
w = o.Width
}
if o.Height > 0 {
h = o.Height
}
return &PDF{w: w, h: h, cur: -1}
}
// Width / Height are the page size in points.
func (p *PDF) Width() float64 { return p.w }
func (p *PDF) Height() float64 { return p.h }
// PageCount is how many pages exist so far.
func (p *PDF) PageCount() int { return len(p.pages) }
// AddPage appends a blank page and makes it current.
func (p *PDF) AddPage() {
p.pages = append(p.pages, &pdfPage{})
p.cur = len(p.pages) - 1
}
// SetPage makes page i (0-based) current, so a later pass can draw on it. Out of
// range is a no-op — a footer loop must never take the app down.
func (p *PDF) SetPage(i int) {
if i >= 0 && i < len(p.pages) {
p.cur = i
}
}
// page returns the current page, creating one if the caller drew before adding.
func (p *PDF) page() *pdfPage {
if p.cur < 0 || p.cur >= len(p.pages) {
p.AddPage()
}
return p.pages[p.cur]
}
// Text draws s with its left edge at x and its BASELINE at y.
func (p *PDF) Text(x, y float64, s string, st PDFTextStyle) {
if s == "" {
return
}
size := st.Size
if size <= 0 {
size = 10
}
font := "/F1"
if st.Bold {
font = "/F2"
}
c := &p.page().content
fmt.Fprintf(c, "BT\n%s %s Tf\n%s %s %s rg\n1 0 0 1 %s %s Tm\n%s Tj\nET\n",
font, pdfNum(size),
pdfNum(st.Color.R), pdfNum(st.Color.G), pdfNum(st.Color.B),
pdfNum(x), pdfNum(y),
pdfString(s),
)
}
// TextRight draws s with its RIGHT edge at x — how a number lands under the right
// edge of its column, and the only reason the width table has to be correct.
func (p *PDF) TextRight(x, y float64, s string, st PDFTextStyle) {
p.Text(x-PDFTextWidth(s, styleSize(st), st.Bold), y, s, st)
}
// TextCenter draws s centered on x.
func (p *PDF) TextCenter(x, y float64, s string, st PDFTextStyle) {
p.Text(x-PDFTextWidth(s, styleSize(st), st.Bold)/2, y, s, st)
}
func styleSize(st PDFTextStyle) float64 {
if st.Size <= 0 {
return 10
}
return st.Size
}
// Line strokes a straight line.
func (p *PDF) Line(x1, y1, x2, y2, thickness float64, color PDFColor) {
if thickness <= 0 {
thickness = 1
}
fmt.Fprintf(&p.page().content, "%s %s %s RG\n%s w\n%s %s m\n%s %s l\nS\n",
pdfNum(color.R), pdfNum(color.G), pdfNum(color.B),
pdfNum(thickness),
pdfNum(x1), pdfNum(y1), pdfNum(x2), pdfNum(y2),
)
}
// Rect fills a rectangle whose lower-left corner is (x,y). There is no stroked
// variant: a table's borders are drawn as lines, so nothing needs one.
func (p *PDF) Rect(x, y, w, h float64, color PDFColor) {
if w <= 0 || h <= 0 {
return
}
fmt.Fprintf(&p.page().content, "%s %s %s rg\n%s %s %s %s re\nf\n",
pdfNum(color.R), pdfNum(color.G), pdfNum(color.B),
pdfNum(x), pdfNum(y), pdfNum(w), pdfNum(h),
)
}
// ---- serialization ----
// Object layout is fixed, which is what keeps the xref arithmetic honest:
//
// 1 Catalog
// 2 Pages
// 3 Helvetica (/F1)
// 4 Helvetica-Bold (/F2)
// 5, 6 page 0: the page object, then its content stream
// 7, 8 page 1: …
const (
pdfObjCatalog = 1
pdfObjPages = 2
pdfObjFont = 3
pdfObjFontBold = 4
)
// pageObjNum / contentObjNum are the object numbers for page i.
func pageObjNum(i int) int { return 5 + 2*i }
func contentObjNum(i int) int { return 6 + 2*i }
// Bytes serializes the document. The result is a complete, standalone PDF file.
func (p *PDF) Bytes() []byte {
pages := p.pages
if len(pages) == 0 {
pages = []*pdfPage{{}} // a zero-page PDF is invalid; ship one blank page
}
nObjs := 4 + 2*len(pages)
var buf bytes.Buffer
buf.WriteString("%PDF-1.4\n")
// The conventional binary marker: four bytes >127 on a comment line, so a tool
// that sniffs the first lines classifies the file as binary and does not
// helpfully mangle its line endings.
buf.Write([]byte{'%', 0xE2, 0xE3, 0xCF, 0xD3, '\n'})
// offsets[n] is the byte offset of object n (index 0 unused).
offsets := make([]int, nObjs+1)
obj := func(n int, body string) {
offsets[n] = buf.Len()
fmt.Fprintf(&buf, "%d 0 obj\n%s\nendobj\n", n, body)
}
stream := func(n int, data []byte) {
offsets[n] = buf.Len()
fmt.Fprintf(&buf, "%d 0 obj\n<< /Length %d >>\nstream\n", n, len(data))
buf.Write(data)
buf.WriteString("\nendstream\nendobj\n")
}
obj(pdfObjCatalog, fmt.Sprintf("<< /Type /Catalog /Pages %d 0 R >>", pdfObjPages))
var kids strings.Builder
for i := range pages {
if i > 0 {
kids.WriteByte(' ')
}
fmt.Fprintf(&kids, "%d 0 R", pageObjNum(i))
}
obj(pdfObjPages, fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", kids.String(), len(pages)))
obj(pdfObjFont, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica /Encoding /WinAnsiEncoding >>")
obj(pdfObjFontBold, "<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica-Bold /Encoding /WinAnsiEncoding >>")
for i, pg := range pages {
obj(pageObjNum(i), fmt.Sprintf(
"<< /Type /Page /Parent %d 0 R /MediaBox [0 0 %s %s] "+
"/Resources << /Font << /F1 %d 0 R /F2 %d 0 R >> >> /Contents %d 0 R >>",
pdfObjPages, pdfNum(p.w), pdfNum(p.h),
pdfObjFont, pdfObjFontBold, contentObjNum(i),
))
stream(contentObjNum(i), pg.content.Bytes())
}
// The cross-reference table. Every entry is exactly 20 bytes — 10-digit
// offset, space, 5-digit generation, space, type, and a two-byte EOL — because
// readers index into it arithmetically rather than parsing it.
xref := buf.Len()
fmt.Fprintf(&buf, "xref\n0 %d\n", nObjs+1)
buf.WriteString("0000000000 65535 f \n")
for n := 1; n <= nObjs; n++ {
fmt.Fprintf(&buf, "%010d 00000 n \n", offsets[n])
}
fmt.Fprintf(&buf, "trailer\n<< /Size %d /Root %d 0 R >>\nstartxref\n%d\n%%%%EOF\n",
nObjs+1, pdfObjCatalog, xref)
return buf.Bytes()
}
// pdfNum formats a coordinate. PDF has no exponent notation, so strconv's 'g'/-1
// shortest form is unusable ("1e-07" is a syntax error in a content stream); and
// three decimals is well past what a 72dpi page can resolve.
func pdfNum(v float64) string {
if v == 0 { // also collapses -0
return "0"
}
s := strconv.FormatFloat(v, 'f', 3, 64)
s = strings.TrimRight(s, "0")
s = strings.TrimSuffix(s, ".")
if s == "" || s == "-" {
return "0"
}
return s
}
// pdfString renders s as a PDF literal string, transcoded to WinAnsi. Anything a
// parser could choke on — the delimiters, the escape character, every byte
// outside printable ASCII — is escaped, the last as three-digit octal.
func pdfString(s string) string {
var b strings.Builder
b.WriteByte('(')
for _, r := range s {
c := winAnsiByte(r)
switch {
case c == '(' || c == ')' || c == '\\':
b.WriteByte('\\')
b.WriteByte(c)
case c < 32 || c > 126:
fmt.Fprintf(&b, "\\%03o", c)
default:
b.WriteByte(c)
}
}
b.WriteByte(')')
return b.String()
}
// ---- encoding ----
// winAnsiByte maps a rune to its WinAnsiEncoding code. Latin-1 passes straight
// through; the printer's punctuation Windows squats in 0x80-0x9F (curly quotes,
// dashes, the ellipsis the truncator appends) is mapped explicitly. Anything else
// — CJK, emoji, a tab in the middle of a cell — becomes '?', because the standard
// 14 fonts have no glyph for it and silently dropping it would misalign the
// column instead.
func winAnsiByte(r rune) byte {
switch {
case r == '\t' || r == '\n' || r == '\r':
return ' '
case r >= 32 && r <= 126:
return byte(r)
case r >= 0xA0 && r <= 0xFF:
return byte(r)
}
if c, ok := winAnsiSpecial[r]; ok {
return c
}
return '?'
}
var winAnsiSpecial = map[rune]byte{
'€': 0x80, // euro
'': 0x82, // single low quote
'ƒ': 0x83, // florin
'„': 0x84, // double low quote
'…': 0x85, // ellipsis <- the truncation marker
'†': 0x86, // dagger
'‡': 0x87, // double dagger
'ˆ': 0x88, // circumflex
'‰': 0x89, // per mille
'Š': 0x8A, // S caron
'': 0x8B, // single left guillemet
'Œ': 0x8C, // OE
'Ž': 0x8E, // Z caron
'': 0x91, // left single quote
'': 0x92, // right single quote (apostrophe)
'“': 0x93, // left double quote
'”': 0x94, // right double quote
'•': 0x95, // bullet
'': 0x96, // en dash
'—': 0x97, // em dash
'˜': 0x98, // small tilde
'™': 0x99, // trademark
'š': 0x9A, // s caron
'': 0x9B, // single right guillemet
'œ': 0x9C, // oe
'ž': 0x9E, // z caron
'Ÿ': 0x9F, // Y dieresis
}
// ---- measurement ----
// PDFTextWidth is the width of s in points, at the given size, in Helvetica (or
// Helvetica-Bold). Widths come from the Adobe AFM tables, in 1/1000 em.
func PDFTextWidth(s string, size float64, bold bool) float64 {
table := &helveticaWidths
if bold {
table = &helveticaBoldWidths
}
total := 0.0
for _, r := range s {
total += float64(table[winAnsiByte(r)])
}
return total * size / 1000
}
// PDFTruncate shortens s until it fits maxWidth, appending an ellipsis — the
// ellipsis being measured too, so the result really does fit. Returns "" when not
// even the ellipsis fits, which is the honest answer for a column that narrow.
func PDFTruncate(s string, maxWidth, size float64, bold bool) string {
if s == "" || PDFTextWidth(s, size, bold) <= maxWidth {
return s
}
runes := []rune(s)
for n := len(runes) - 1; n > 0; n-- {
if PDFTextWidth(string(runes[:n])+"…", size, bold) <= maxWidth {
return string(runes[:n]) + "…"
}
}
if PDFTextWidth("…", size, bold) <= maxWidth {
return "…"
}
return ""
}
// The Helvetica / Helvetica-Bold advance widths, indexed by WinAnsi code, in
// 1/1000 em — the Adobe Core-14 AFM data, which is what "no font embedding
// required" actually costs you: the widths have to live somewhere, and this is
// where.
//
// Codes 32-126 are the exact AFM values (note 39 is quotesingle and 96 is grave
// under WinAnsi, NOT quoteright/quoteleft as under StandardEncoding — a classic
// off-by-one-glyph). 0x80-0xFF are the AFM values for the Latin-1 and printer's
// punctuation glyphs; the accented letters carry the advance width of their base
// letter, which is exactly how the composite glyphs are built. Unmapped codes
// (0x81, 0x8D, 0x8F, 0x90, 0x9D) fall back to the width of 'n', so a stray byte
// costs a plausible amount of space rather than zero.
var (
helveticaWidths [256]uint16
helveticaBoldWidths [256]uint16
)
// helveticaAscii/helveticaBoldAscii are codes 32..126, in order.
var helveticaAscii = [95]uint16{
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, // 32-41 space ! " # $ % & ' ( )
389, 584, 278, 333, 278, 278, 556, 556, 556, 556, // 42-51 * + , - . / 0 1 2 3
556, 556, 556, 556, 556, 556, 278, 278, 584, 584, // 52-61 4 5 6 7 8 9 : ; < =
584, 556, 1015, 667, 667, 722, 722, 667, 611, 778, // 62-71 > ? @ A B C D E F G
722, 278, 500, 667, 556, 833, 722, 778, 667, 778, // 72-81 H I J K L M N O P Q
722, 667, 611, 722, 667, 944, 667, 667, 611, 278, // 82-91 R S T U V W X Y Z [
278, 278, 469, 556, 333, 556, 556, 500, 556, 556, // 92-101 \ ] ^ _ ` a b c d e
278, 556, 556, 222, 222, 500, 222, 833, 556, 556, // 102-111 f g h i j k l m n o
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, // 112-121 p q r s t u v w x y
500, 334, 260, 334, 584, // 122-126 z { | } ~
}
var helveticaBoldAscii = [95]uint16{
278, 333, 474, 556, 556, 889, 722, 238, 333, 333, // 32-41
389, 584, 278, 333, 278, 278, 556, 556, 556, 556, // 42-51
556, 556, 556, 556, 556, 556, 333, 333, 584, 584, // 52-61
584, 611, 975, 722, 722, 722, 722, 667, 611, 778, // 62-71
722, 278, 556, 722, 611, 833, 722, 778, 667, 778, // 72-81
722, 667, 611, 722, 667, 944, 667, 667, 611, 333, // 82-91
278, 333, 584, 556, 333, 556, 611, 556, 611, 556, // 92-101
333, 611, 611, 278, 278, 556, 278, 889, 611, 611, // 102-111
611, 611, 389, 556, 333, 611, 556, 778, 556, 556, // 112-121
500, 389, 280, 389, 584, // 122-126
}
// The high half: {code: {regular, bold}}.
var helveticaHigh = map[byte][2]uint16{
0x80: {556, 556}, // euro
0x82: {222, 278}, // quotesinglbase
0x83: {556, 556}, // florin
0x84: {333, 500}, // quotedblbase
0x85: {1000, 1000}, // ellipsis
0x86: {556, 556}, // dagger
0x87: {556, 556}, // daggerdbl
0x88: {333, 333}, // circumflex
0x89: {1000, 1000}, // perthousand
0x8A: {667, 667}, // Scaron
0x8B: {333, 333}, // guilsinglleft
0x8C: {1000, 1000}, // OE
0x8E: {611, 611}, // Zcaron
0x91: {222, 278}, // quoteleft
0x92: {222, 278}, // quoteright
0x93: {333, 500}, // quotedblleft
0x94: {333, 500}, // quotedblright
0x95: {350, 350}, // bullet
0x96: {556, 556}, // endash
0x97: {1000, 1000}, // emdash
0x98: {333, 333}, // tilde
0x99: {1000, 1000}, // trademark
0x9A: {500, 556}, // scaron
0x9B: {333, 333}, // guilsinglright
0x9C: {944, 944}, // oe
0x9E: {500, 500}, // zcaron
0x9F: {667, 667}, // Ydieresis
0xA0: {278, 278}, // nbsp
0xA1: {333, 333}, // exclamdown
0xA2: {556, 556}, // cent
0xA3: {556, 556}, // sterling
0xA4: {556, 556}, // currency
0xA5: {556, 556}, // yen
0xA6: {260, 280}, // brokenbar
0xA7: {556, 556}, // section
0xA8: {333, 333}, // dieresis
0xA9: {737, 737}, // copyright
0xAA: {370, 370}, // ordfeminine
0xAB: {556, 556}, // guillemotleft
0xAC: {584, 584}, // logicalnot
0xAD: {333, 333}, // soft hyphen
0xAE: {737, 737}, // registered
0xAF: {333, 333}, // macron
0xB0: {400, 400}, // degree
0xB1: {584, 584}, // plusminus
0xB2: {333, 333}, // twosuperior
0xB3: {333, 333}, // threesuperior
0xB4: {333, 333}, // acute
0xB5: {556, 611}, // mu
0xB6: {537, 556}, // paragraph
0xB7: {278, 278}, // periodcentered
0xB8: {333, 333}, // cedilla
0xB9: {333, 333}, // onesuperior
0xBA: {365, 365}, // ordmasculine
0xBB: {556, 556}, // guillemotright
0xBC: {834, 834}, // onequarter
0xBD: {834, 834}, // onehalf
0xBE: {834, 834}, // threequarters
0xBF: {611, 611}, // questiondown
0xC6: {1000, 1000}, // AE
0xD0: {722, 722}, // Eth
0xD7: {584, 584}, // multiply
0xD8: {778, 778}, // Oslash
0xDD: {667, 667}, // Yacute
0xDE: {667, 667}, // Thorn
0xDF: {611, 611}, // germandbls
0xE6: {889, 889}, // ae
0xF0: {556, 611}, // eth
0xF7: {584, 584}, // divide
0xF8: {611, 611}, // oslash
0xFD: {500, 556}, // yacute
0xFE: {556, 611}, // thorn
0xFF: {500, 556}, // ydieresis
}
// accentBase maps the composite Latin-1 letters to the ASCII letter whose advance
// width they share (Helvetica builds them by stacking an accent over the base
// glyph, which does not widen it). The accented i's are the exception: they are
// built over dotlessi, which is wider than i.
var accentBase = map[byte]byte{
0xC0: 'A', 0xC1: 'A', 0xC2: 'A', 0xC3: 'A', 0xC4: 'A', 0xC5: 'A',
0xC7: 'C',
0xC8: 'E', 0xC9: 'E', 0xCA: 'E', 0xCB: 'E',
0xD1: 'N',
0xD2: 'O', 0xD3: 'O', 0xD4: 'O', 0xD5: 'O', 0xD6: 'O',
0xD9: 'U', 0xDA: 'U', 0xDB: 'U', 0xDC: 'U',
0xE0: 'a', 0xE1: 'a', 0xE2: 'a', 0xE3: 'a', 0xE4: 'a', 0xE5: 'a',
0xE7: 'c',
0xE8: 'e', 0xE9: 'e', 0xEA: 'e', 0xEB: 'e',
0xF1: 'n',
0xF2: 'o', 0xF3: 'o', 0xF4: 'o', 0xF5: 'o', 0xF6: 'o',
0xF9: 'u', 0xFA: 'u', 0xFB: 'u', 0xFC: 'u',
}
func init() {
// dotlessi's advance — what the accented i's (0xCC-0xCF, 0xEC-0xEF) are built on.
const dotlessI, dotlessIBold = 278, 278
fill := func(dst *[256]uint16, ascii *[95]uint16, pick int, dotless uint16) {
fallback := ascii['n'-32]
for i := range dst {
dst[i] = fallback
}
for i, w := range ascii {
dst[32+i] = w
}
for c, w := range helveticaHigh {
dst[c] = w[pick]
}
for c, base := range accentBase {
dst[c] = ascii[base-32]
}
for _, c := range []byte{0xCC, 0xCD, 0xCE, 0xCF, 0xEC, 0xED, 0xEE, 0xEF} {
dst[c] = dotless
}
// Codes 0-31 are unprintable and never emitted (winAnsiByte folds
// whitespace to a space), but give them the space width anyway so a
// measurement can never be wildly off.
for i := 0; i < 32; i++ {
dst[i] = ascii[0]
}
}
fill(&helveticaWidths, &helveticaAscii, 0, dotlessI)
fill(&helveticaBoldWidths, &helveticaBoldAscii, 1, dotlessIBold)
}

View File

@@ -0,0 +1,91 @@
package webui
import (
"regexp"
"strconv"
"strings"
"testing"
)
// pdfHorizontalRules extracts every horizontal line the PDF draws, as (y, width).
// The content stream draws a line as: "<w> w <x1> <y> m <x2> <y> l S".
func pdfHorizontalRules(pdf []byte) map[string]int {
re := regexp.MustCompile(`([\d.]+) w\s*\n?[^\n]*?([\d.]+) ([\d.]+) m\s*\n?[^\n]*?([\d.]+) ([\d.]+) l`)
out := map[string]int{}
for _, m := range re.FindAllStringSubmatch(string(pdf), -1) {
y1, y2 := m[3], m[5]
if y1 != y2 {
continue // not horizontal
}
out[y1]++
}
return out
}
// The bug: the row loop drew a rule under EVERY row including the last, and then the
// summary block drew its divider at exactly the same y. Two rules at one y read as a
// double border between the table and its summaries.
func TestPDFDrawsOneRuleBetweenTableAndSummaries(t *testing.T) {
s := newCalcTable()
s.AddSummaryRow(UserSummaryRow{ID: "t", Label: "Total", Fn: CALC_FN_SUM,
Operands: []string{"Revenue"}, DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0)})
s.Render()
rules := pdfHorizontalRules(s.ExportPDFBytes(AutoTablePDFHeader{Title: "R"}))
if len(rules) == 0 {
t.Fatal("no horizontal rules found — the extractor is wrong, not the PDF")
}
for y, n := range rules {
if n > 1 {
t.Errorf("%d rules stacked at y=%s — that is the double border", n, y)
}
}
}
// Without summaries the table still gets its closing rule; removing the last row's
// rule must not leave the table open at the bottom.
func TestPDFClosesTheTableWithoutSummaries(t *testing.T) {
s := newCalcTable()
s.Render()
pdf := s.ExportPDFBytes(AutoTablePDFHeader{Title: "R"})
rules := pdfHorizontalRules(pdf)
if len(rules) < 2 {
t.Errorf("expected a header rule and a closing rule, got %d distinct rules", len(rules))
}
for y, n := range rules {
if n > 1 {
t.Errorf("%d rules stacked at y=%s", n, y)
}
}
// And the lowest rule sits below the last row of text, i.e. the table is closed.
lowest := 1e9
for y := range rules {
if f, err := strconv.ParseFloat(y, 64); err == nil && f < lowest {
lowest = f
}
}
if lowest > 700 || !strings.Contains(string(pdf), "%%EOF") {
t.Errorf("closing rule at y=%v looks wrong", lowest)
}
}
// The extractor must actually see stacked rules, or the two tests above are vacuous.
func TestPDFRuleExtractorCatchesADoubledRule(t *testing.T) {
pdf := NewPDF(PDFOptions{})
pdf.Line(50, 300, 500, 300, 0.5, PDFColor{})
pdf.Line(50, 300, 500, 300, 1, PDFColor{}) // deliberately stacked
pdf.Line(50, 200, 500, 200, 0.5, PDFColor{})
rules := pdfHorizontalRules(pdf.Bytes())
doubled := 0
for _, n := range rules {
if n > 1 {
doubled++
}
}
if doubled != 1 {
t.Fatalf("extractor found %d stacked positions, want exactly 1 — it cannot see the bug it is meant to catch (%v)", doubled, rules)
}
}

View File

@@ -0,0 +1,66 @@
package webui
import (
"strings"
"testing"
)
// A summary row the user built in the editor must reach the PDF. Before, the caller
// had to restate it by hand — so a row someone created showed on screen and then
// quietly vanished from the export, which is the one number they most likely wanted
// in the file.
func TestPDFPicksUpTheTablesOwnSummaryRows(t *testing.T) {
s := newCalcTable()
s.AddSummaryRow(UserSummaryRow{
ID: "total", Label: "Total revenue", Fn: CALC_FN_SUM,
Operands: []string{"Revenue"}, DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
})
s.Render()
pdf := string(s.ExportPDFBytes(AutoTablePDFHeader{Title: "Report"}))
if !strings.Contains(pdf, "Total revenue") {
t.Error("the table's own summary row is missing from the PDF")
}
if !strings.Contains(pdf, "300") {
t.Error("the summary row's VALUE is missing from the PDF (100 + 200)")
}
}
// A calculated column the user built must reach it too, with its per-row values.
func TestPDFPicksUpCalculatedColumns(t *testing.T) {
s := newCalcTable()
s.AddCalculated(UserCalculatedColumn{
ID: "total", DisplayName: "Row total", Fn: CALC_FN_SUM,
Operands: []string{"Revenue", "Cost"}, DataType: CALC_TYPE_NUMBER, Precision: CalcPrecision(0),
})
s.Render()
pdf := string(s.ExportPDFBytes(AutoTablePDFHeader{Title: "Report"}))
if !strings.Contains(pdf, "Row total") {
t.Error("the calculated column's header is missing from the PDF")
}
// Per row, not the column total: 100+40 and 200+50.
for _, want := range []string{"140", "250"} {
if !strings.Contains(pdf, want) {
t.Errorf("calculated value %s missing from the PDF", want)
}
}
}
// An explicit Summaries list is an escape hatch and must win.
func TestPDFExplicitSummariesWin(t *testing.T) {
s := newCalcTable()
s.AddSummaryRow(UserSummaryRow{ID: "a", Label: "From the table", Fn: CALC_FN_SUM,
Operands: []string{"Revenue"}})
s.Render()
pdf := string(s.ExportPDFBytes(AutoTablePDFHeader{
Summaries: []AutoTablePDFSummary{{Label: "Caller supplied", Value: "42"}},
}))
if !strings.Contains(pdf, "Caller supplied") {
t.Error("an explicit summary list was ignored")
}
if strings.Contains(pdf, "From the table") {
t.Error("the table's rows were added on top of the caller's")
}
}

391
go/webui/pdf_test.go Normal file
View File

@@ -0,0 +1,391 @@
package webui
import (
"bytes"
"fmt"
"strconv"
"strings"
"testing"
)
// ---- a structural validator for the PDFs we emit -------------------------
//
// The point of these tests is the cross-reference table. An xref whose offsets do
// not land exactly on the objects they claim to is the classic way a hand-rolled
// PDF writer breaks: the file still "looks fine" (it starts with %PDF-, it ends
// with %%EOF, it is full of plausible text) and every reader rejects it. So the
// validator below walks the xref the way a reader does — arithmetically, 20 bytes
// per entry — and insists each offset points at "<n> 0 obj".
type pdfInfo struct {
objects int // objects declared by the xref (excluding the free head)
pageCount int // /Count in the Pages node
pageObjs int // page objects actually present
xrefAt int
}
func checkPDF(data []byte) (pdfInfo, error) {
var info pdfInfo
if !bytes.HasPrefix(data, []byte("%PDF-")) {
return info, fmt.Errorf("does not start with %%PDF-")
}
if !bytes.HasSuffix(bytes.TrimRight(data, "\r\n"), []byte("%%EOF")) {
return info, fmt.Errorf("does not end with %%%%EOF")
}
// startxref -> the byte offset of the xref table.
i := bytes.LastIndex(data, []byte("startxref"))
if i < 0 {
return info, fmt.Errorf("no startxref")
}
fields := strings.Fields(string(data[i+len("startxref"):]))
if len(fields) == 0 {
return info, fmt.Errorf("startxref has no offset")
}
xrefAt, err := strconv.Atoi(fields[0])
if err != nil {
return info, fmt.Errorf("startxref offset %q: %v", fields[0], err)
}
if xrefAt < 0 || xrefAt >= len(data) {
return info, fmt.Errorf("startxref offset %d out of range (len %d)", xrefAt, len(data))
}
info.xrefAt = xrefAt
p := xrefAt
if !bytes.HasPrefix(data[p:], []byte("xref\n")) {
return info, fmt.Errorf("startxref does not point at an xref table (found %q)", peek(data, p))
}
p += len("xref\n")
// Subsection header: "0 <size>".
nl := bytes.IndexByte(data[p:], '\n')
if nl < 0 {
return info, fmt.Errorf("truncated xref subsection header")
}
head := strings.Fields(string(data[p : p+nl]))
p += nl + 1
if len(head) != 2 || head[0] != "0" {
return info, fmt.Errorf("unexpected xref subsection header %q", head)
}
size, err := strconv.Atoi(head[1])
if err != nil {
return info, fmt.Errorf("xref size %q: %v", head[1], err)
}
info.objects = size - 1
if p+20*size > len(data) {
return info, fmt.Errorf("xref table is truncated: needs %d bytes, %d left", 20*size, len(data)-p)
}
// Entry 0 is the head of the free list, and is required to look exactly so.
if got := string(data[p : p+20]); got != "0000000000 65535 f \n" {
return info, fmt.Errorf("bad free entry %q", got)
}
for n := 1; n < size; n++ {
entry := string(data[p+20*n : p+20*(n+1)])
if len(entry) != 20 || entry[10] != ' ' || entry[17] != 'n' {
return info, fmt.Errorf("object %d: malformed xref entry %q", n, entry)
}
off, err := strconv.Atoi(entry[:10])
if err != nil {
return info, fmt.Errorf("object %d: bad offset %q", n, entry[:10])
}
if off <= 0 || off >= len(data) {
return info, fmt.Errorf("object %d: offset %d out of range (len %d)", n, off, len(data))
}
want := []byte(strconv.Itoa(n) + " 0 obj")
if !bytes.HasPrefix(data[off:], want) {
return info, fmt.Errorf("object %d: xref offset %d points at %q, not %q", n, off, peek(data, off), want)
}
}
p += 20 * size
if !bytes.HasPrefix(data[p:], []byte("trailer")) {
return info, fmt.Errorf("no trailer after the xref table (found %q)", peek(data, p))
}
if !bytes.Contains(data[p:], []byte("/Size "+strconv.Itoa(size))) {
return info, fmt.Errorf("trailer /Size disagrees with the xref subsection (%d)", size)
}
if !bytes.Contains(data[p:], []byte("/Root 1 0 R")) {
return info, fmt.Errorf("trailer has no /Root")
}
// Every object the xref promises must actually be there.
for n := 1; n <= info.objects; n++ {
if !bytes.Contains(data, []byte("\n"+strconv.Itoa(n)+" 0 obj\n")) {
return info, fmt.Errorf("object %d is declared but absent", n)
}
}
info.pageObjs = bytes.Count(data, []byte("/Type /Page /Parent"))
if i := bytes.Index(data, []byte("/Type /Pages /Kids")); i >= 0 {
if c := bytes.Index(data[i:], []byte("/Count ")); c >= 0 {
f := strings.Fields(string(data[i+c+len("/Count "):]))
if len(f) > 0 {
info.pageCount, _ = strconv.Atoi(strings.TrimRight(f[0], ">"))
}
}
} else {
return info, fmt.Errorf("no /Pages node")
}
if info.pageCount != info.pageObjs {
return info, fmt.Errorf("/Count says %d pages, but %d page objects exist", info.pageCount, info.pageObjs)
}
// catalog + pages + 2 fonts + (page, content) per page.
if want := 4 + 2*info.pageObjs; want != info.objects {
return info, fmt.Errorf("object count is %d, want %d for %d pages", info.objects, want, info.pageObjs)
}
return info, nil
}
func peek(data []byte, at int) string {
end := min(at+24, len(data))
return string(data[at:end])
}
func mustCheckPDF(t *testing.T, data []byte) pdfInfo {
t.Helper()
info, err := checkPDF(data)
if err != nil {
t.Fatalf("invalid PDF: %v", err)
}
return info
}
// ---- the writer ----------------------------------------------------------
func TestPDFStructure(t *testing.T) {
p := NewPDF(PDFOptions{})
for i := range 3 {
p.AddPage()
p.Text(40, 500, "Page "+strconv.Itoa(i), PDFTextStyle{Size: 10, Color: PDFGray(0.1)})
p.Line(40, 480, 500, 480, 1, PDFGray(0.8))
p.Rect(40, 400, 200, 40, PDFGray(0.95))
}
data := p.Bytes()
info := mustCheckPDF(t, data)
if info.pageObjs != 3 {
t.Fatalf("page objects = %d, want 3", info.pageObjs)
}
if info.objects != 10 { // 4 fixed + 2 per page
t.Fatalf("objects = %d, want 10", info.objects)
}
}
// The validator is only worth anything if it actually fails on a broken xref.
// Shifting every object by a byte (without rewriting the table) is precisely the
// bug a naive writer ships.
func TestCheckPDFCatchesABrokenXref(t *testing.T) {
p := NewPDF(PDFOptions{})
p.AddPage()
p.Text(40, 40, "hi", PDFTextStyle{Size: 10})
good := p.Bytes()
if _, err := checkPDF(good); err != nil {
t.Fatalf("the good document must pass first: %v", err)
}
// Insert a byte after the file header: every object now sits one byte later
// than the xref claims.
broken := append([]byte{}, good[:9]...)
broken = append(broken, '\n')
broken = append(broken, good[9:]...)
if _, err := checkPDF(broken); err == nil {
t.Fatal("a document whose objects have all shifted by a byte must NOT validate")
}
}
func TestPDFEmptyDocumentStillHasAPage(t *testing.T) {
// A PDF with zero pages is not a valid PDF, so Bytes() must not emit one.
info := mustCheckPDF(t, NewPDF(PDFOptions{}).Bytes())
if info.pageObjs != 1 {
t.Fatalf("page objects = %d, want 1", info.pageObjs)
}
}
func TestPDFOrientation(t *testing.T) {
land := NewPDF(PDFOptions{Orientation: PDF_ORIENTATION_LANDSCAPE})
if land.Width() != 792 || land.Height() != 612 {
t.Fatalf("landscape = %vx%v, want 792x612", land.Width(), land.Height())
}
port := NewPDF(PDFOptions{Orientation: PDF_ORIENTATION_PORTRAIT})
if port.Width() != 612 || port.Height() != 792 {
t.Fatalf("portrait = %vx%v, want 612x792", port.Width(), port.Height())
}
land.AddPage()
if !bytes.Contains(land.Bytes(), []byte("/MediaBox [0 0 792 612]")) {
t.Error("landscape MediaBox is wrong")
}
port.AddPage()
if !bytes.Contains(port.Bytes(), []byte("/MediaBox [0 0 612 792]")) {
t.Error("portrait MediaBox is wrong")
}
}
func TestPDFSetPageDrawsOnAnEarlierPage(t *testing.T) {
// This is what the "Page 1 of 7" footer needs: you cannot know the total until
// every page exists, so the footer is stamped on afterwards.
p := NewPDF(PDFOptions{})
p.AddPage()
p.AddPage()
p.SetPage(0)
p.Text(40, 20, "footer-marker", PDFTextStyle{Size: 8})
p.SetPage(99) // out of range: a no-op, not a panic
data := p.Bytes()
mustCheckPDF(t, data)
if n := bytes.Count(data, []byte("(footer-marker)")); n != 1 {
t.Fatalf("footer text appears %d times, want 1", n)
}
// It has to be in the FIRST page's content stream: object 6 (page 0's content),
// not object 8.
first := objectBody(t, data, 6)
if !bytes.Contains(first, []byte("(footer-marker)")) {
t.Error("SetPage(0) did not draw on the first page")
}
}
// objectBody returns the bytes of object n, from its header to "endobj".
func objectBody(t *testing.T, data []byte, n int) []byte {
t.Helper()
start := bytes.Index(data, []byte("\n"+strconv.Itoa(n)+" 0 obj\n"))
if start < 0 {
t.Fatalf("object %d not found", n)
}
end := bytes.Index(data[start:], []byte("endobj"))
if end < 0 {
t.Fatalf("object %d has no endobj", n)
}
return data[start : start+end]
}
func TestPDFStringEscaping(t *testing.T) {
p := NewPDF(PDFOptions{})
p.AddPage()
p.Text(10, 10, `a(b)c\d`, PDFTextStyle{Size: 8})
p.Text(10, 20, "café ¥", PDFTextStyle{Size: 8})
p.Text(10, 30, "漢字", PDFTextStyle{Size: 8})
data := p.Bytes()
mustCheckPDF(t, data)
body := string(objectBody(t, data, 6))
if !strings.Contains(body, `(a\(b\)c\\d)`) {
t.Errorf("delimiters/backslash not escaped:\n%s", body)
}
// é is WinAnsi 0xE9 = octal 351, ¥ is 0xA5 = octal 245.
if !strings.Contains(body, `(caf\351 \245)`) {
t.Errorf("high bytes not octal-escaped:\n%s", body)
}
// The standard fonts have no CJK glyph; each rune degrades to '?' rather than
// silently vanishing (which would misalign the column).
if !strings.Contains(body, "(??)") {
t.Errorf("unmappable runes not replaced:\n%s", body)
}
}
func TestPDFNumberFormatting(t *testing.T) {
// PDF has no exponent notation: a coordinate like 1e-07 is a syntax error.
for _, tc := range []struct {
in float64
want string
}{
{0, "0"},
{0.0000001, "0"},
{1, "1"},
{-0.5, "-0.5"},
{123.456789, "123.457"},
{792, "792"},
} {
if got := pdfNum(tc.in); got != tc.want {
t.Errorf("pdfNum(%v) = %q, want %q", tc.in, got, tc.want)
}
}
}
// ---- the Helvetica width tables ------------------------------------------
// A typo anywhere in the width table silently misaligns every column that uses
// the glyph, so pin the values that are easy to get wrong: the two fonts differ,
// 'i' is narrow and 'W' is wide, and under WinAnsi code 39 is quotesingle (191),
// NOT quoteright (222).
func TestHelveticaWidths(t *testing.T) {
// Measured at size 1000, a width in points equals the AFM value directly.
cases := []struct {
s string
bold bool
want float64
}{
{" ", false, 278},
{"A", false, 667},
{"W", false, 944},
{"i", false, 222},
{"l", false, 222},
{"m", false, 833},
{"0", false, 556},
{"9", false, 556},
{"$", false, 556},
{"@", false, 1015},
{"'", false, 191}, // quotesingle under WinAnsi
{"`", false, 333}, // grave under WinAnsi
{"…", false, 1000}, // the truncation marker
{"é", false, 556}, // composite: the width of 'e'
{"€", false, 556},
{" ", true, 278},
{"A", true, 722},
{"W", true, 944},
{"i", true, 278},
{"m", true, 889},
{"0", true, 556},
{"'", true, 238},
{"z", true, 500},
}
for _, c := range cases {
if got := PDFTextWidth(c.s, 1000, c.bold); got != c.want {
t.Errorf("PDFTextWidth(%q, bold=%v) = %v, want %v", c.s, c.bold, got, c.want)
}
}
// Widths scale linearly with the font size, and add up across a string.
if got, want := PDFTextWidth("AW", 10, false), (667.0+944.0)*10/1000; got != want {
t.Errorf("PDFTextWidth(\"AW\", 10) = %v, want %v", got, want)
}
if got := PDFTextWidth("", 10, false); got != 0 {
t.Errorf("the empty string measures %v, want 0", got)
}
}
func TestPDFTruncate(t *testing.T) {
const size = 8
long := "Supercalifragilisticexpialidocious"
full := PDFTextWidth(long, size, false)
// It fits: untouched.
if got := PDFTruncate(long, full, size, false); got != long {
t.Errorf("a string that fits was truncated: %q", got)
}
// It does not: truncated, ellipsized, and — the part that matters — the RESULT
// actually fits, ellipsis included.
got := PDFTruncate(long, full/2, size, false)
if !strings.HasSuffix(got, "…") {
t.Errorf("truncated %q has no ellipsis", got)
}
if w := PDFTextWidth(got, size, false); w > full/2 {
t.Errorf("truncated %q measures %v, over the %v limit", got, w, full/2)
}
if len([]rune(got)) >= len([]rune(long)) {
t.Errorf("truncated %q is not shorter than the original", got)
}
// Narrower than the ellipsis itself: nothing can be drawn, and saying so is
// better than overflowing the column.
if got := PDFTruncate(long, 1, size, false); got != "" {
t.Errorf("PDFTruncate(_, 1pt) = %q, want \"\"", got)
}
}

View File

@@ -1,165 +1,168 @@
// Port of web/kit/Popovers.tsx.
// Port of web/uikit/Popovers.tsx, rebuilt on the Floating controller (floating.go).
//
// NOTE: the TSX builds these on Floating.tsx — a floating-ui-style layer with
// getBoundingClientRect measurement, requestAnimationFrame reposition, a Portal
// to document.body, a global single-open manager, outside-click/Escape handling,
// and hover open/close timers, all threaded through a FloatingContext. None of
// that has an equivalent in the neutral runtime (no document, portals, refs,
// element measurement, or timers), so it is dropped. This port keeps the
// component API + Tailwind + event wiring, drives click open/close with a
// caller-supplied `open` bool + toggle callback, does hover reveal purely in CSS
// (group-hover), and approximates placement with static absolute utility classes
// instead of computed coordinates (so flip/shift and a numeric offset are gone —
// the gap is a fixed ~8px via the m*-2 classes, matching the TSX default offset).
// The first Go port dropped everything that made a popover a popover: measured
// placement (it used static absolute utilities off a `relative inline-block`
// wrapper), the portal, the single-open manager, outside-click / Escape dismissal,
// and — for HoverPopover — the hover bridge, which it faked with Tailwind's
// `group-hover`. That fake only worked because the panel was a DOM descendant of the
// hovered wrapper. The panel is now PORTALED to document.body, so it is not a
// descendant of anything the cursor is over and CSS :hover cannot reach it: the
// reveal MUST be the timer-based bridge in Floating (OpenOnHover + HoverDelay /
// HoverCloseDelay), which holds the panel open while the cursor crosses the gap and
// while it rests on the panel itself. That is what NewHoverPopover wires up.
//
// Shape of the port: the TSX's <Popover> element was only a carrier for a Solid
// context, which Go has no equivalent of, so it becomes a CONTROLLER instead — the
// state (open, refs, timers, resolved placement) has to outlive a render, so it
// cannot be created inside one. There is no wrapper element left: the trigger sits
// where you put it and the panel is portaled to the body, so nothing needs to
// contain them both.
//
// pop := webui.NewPopover(webui.PopoverProps{Placement: webui.PlacementBottomEnd})
// return func() *vdom.VNode {
// return vdom.Div(
// pop.Trigger(webui.PopoverTriggerProps{Class: "btn"}, vdom.Text("Filters")),
// pop.Content(webui.PopoverContentProps{Class: "p-3 w-64"}, body()...),
// )
// }
package webui
import "kjol/vdom"
const popoverCls = "bg-white rounded-default shadow-lg border border-neutral-200"
// popoverPlacementCls maps a floating placement to static absolute-position
// utility classes relative to the Popover's `relative` wrapper.
func popoverPlacementCls(placement string) string {
switch placement {
case "top", "top-start":
return "bottom-full left-0 mb-2"
case "top-end":
return "bottom-full right-0 mb-2"
case "bottom-end":
return "top-full right-0 mt-2"
case "left", "left-start":
return "right-full top-0 mr-2"
case "left-end":
return "right-full bottom-0 mr-2"
case "right", "right-start":
return "left-full top-0 ml-2"
case "right-end":
return "left-full bottom-0 ml-2"
default: // "bottom" / "bottom-start" and unknown
return "top-full left-0 mt-2"
}
}
// TSX defaults: offset ?? 8, hoverDelay ?? 0, hoverCloseDelay ?? 150.
const popoverOffset = 8
// PopoverProps configures Popover. Placement defaults to "bottom-start".
// PopoverProps configures NewPopover.
type PopoverProps struct {
// Placement defaults to "bottom-start"; the resolved placement may differ after
// a flip (read it with Placement()).
Placement string
Class string
// Offset is the gap between trigger and panel in px; 0 means the TSX default, 8.
Offset float64
// Standalone opts out of the single-open manager: this popover neither closes,
// nor is closed by, other floatings. Set it for a popover nested inside another
// floating (a popover opened from a menu item), which would otherwise close its
// own parent the moment it opened.
Standalone bool
// OnOpenChange is called whenever the panel opens or closes — for callers that
// mirror the state (the popover owns it either way; there is no `Open` input,
// because a controller that both owns state and takes it is a race).
OnOpenChange func(bool)
}
// Popover is the floating wrapper: a relative container that anchors an
// absolutely-positioned PopoverContent to a PopoverTrigger. Compose a
// PopoverTrigger and a PopoverContent as its children.
func Popover(p PopoverProps, children ...*vdom.VNode) *vdom.VNode {
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("relative inline-block", p.Class))}, children)...)
}
// PopoverTriggerProps configures PopoverTrigger. Open drives aria-expanded and
// OnToggle fires on click (the TSX toggles open on click).
type PopoverTriggerProps struct {
Open bool
OnToggle func()
Class string
Title string
}
// PopoverTrigger is the click target that opens/closes the popover.
func PopoverTrigger(p PopoverTriggerProps, children ...*vdom.VNode) *vdom.VNode {
expanded := "false"
if p.Open {
expanded = "true"
}
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", p.Class),
vdom.Attr("aria-expanded", expanded),
vdom.Attr("aria-haspopup", "menu"),
}
if p.Title != "" {
mods = append(mods, vdom.Attr("title", p.Title))
}
if p.OnToggle != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
}
return vdom.El("button", kids(mods, children)...)
}
// PopoverContentProps configures PopoverContent. Placement defaults to
// "bottom-start"; Open toggles visibility (nil when closed).
type PopoverContentProps struct {
Open bool
Placement string
Class string
}
// PopoverContent is the floating panel. It renders nil when Open is false.
func PopoverContent(p PopoverContentProps, children ...*vdom.VNode) *vdom.VNode {
if !p.Open {
return nil
}
mods := []vdom.Mod{
vdom.Attr("role", "menu"),
vdom.Attr("data-floating-content", "true"),
vdom.Attr("class", cx("absolute z-[110]", popoverPlacementCls(p.Placement), popoverCls, p.Class)),
}
return vdom.El("div", kids(mods, children)...)
}
// HoverPopoverProps configures HoverPopover. Placement defaults to
// "bottom-start".
// HoverPopoverProps configures NewHoverPopover. It is PopoverProps plus the two
// hover timings (the TSX's HoverPopoverProps extends PopoverProps the same way).
type HoverPopoverProps struct {
Placement string
Offset float64
Standalone bool
OnOpenChange func(bool)
// HoverDelay is how long the cursor must rest on the trigger before the panel
// opens (default 0 — the TSX default). HoverCloseDelay is the grace period after
// the cursor leaves the trigger OR the panel: the bridge across the gap between
// them (default 150ms).
HoverDelay int
HoverCloseDelay int
}
// Popover is a live popover — the controller that NewPopover and NewHoverPopover
// both return. Build it once, alongside your signals, NOT inside a render function
// (a Floating rebuilt every frame would lose its refs, its timers and its open
// state, i.e. it would never open).
type Popover struct{ f *Floating }
// NewPopover creates a click-to-open popover.
func NewPopover(p PopoverProps) *Popover {
return &Popover{f: NewFloating(FloatingOptions{
Placement: pick(p.Placement, PlacementBottomStart),
Offset: pickOffset(p.Offset, popoverOffset),
Standalone: p.Standalone,
OnOpenChange: p.OnOpenChange,
})}
}
// NewHoverPopover creates a hover-to-open popover.
//
// The hover bridge lives in Floating and is wired on BOTH the trigger and the panel
// (Trigger/Panel install it when OpenOnHover is set), so the panel survives the
// cursor's trip across the gap and stays up while the cursor is on it — the thing
// the old CSS group-hover version could not do once the panel was portaled out of
// the wrapper.
func NewHoverPopover(p HoverPopoverProps) *Popover {
return &Popover{f: NewFloating(FloatingOptions{
Placement: pick(p.Placement, PlacementBottomStart),
Offset: pickOffset(p.Offset, popoverOffset),
Standalone: p.Standalone,
OnOpenChange: p.OnOpenChange,
OpenOnHover: true,
HoverDelay: p.HoverDelay,
HoverCloseDelay: p.HoverCloseDelay, // 0 → NewFloating's 150ms
})}
}
// Floating exposes the underlying controller (Reposition, Dispose, ...).
func (p *Popover) Floating() *Floating { return p.f }
// IsOpen reports the current state; Placement is the placement AFTER any flip.
func (p *Popover) IsOpen() bool { return p.f.IsOpen() }
func (p *Popover) Placement() string { return p.f.Placement() }
// Show, Hide and Toggle drive the panel from outside (a keyboard shortcut, a
// "close" button inside the panel, a route change).
func (p *Popover) Show() { p.f.Show() }
func (p *Popover) Hide() { p.f.Hide() }
func (p *Popover) Toggle() { p.f.Toggle() }
// Dispose closes the panel and drops every listener and timer it owns.
func (p *Popover) Dispose() { p.f.Dispose() }
// PopoverTriggerProps configures Trigger. The Open/OnToggle fields the old port had
// are gone: the Popover owns its open state now, and the trigger it renders drives
// it (click for NewPopover, hover/focus for NewHoverPopover).
type PopoverTriggerProps struct {
Class string
Title string
// OnClick runs in addition to the open/close (for a hover popover, whose trigger
// is not a toggle, it is the only thing a click does).
OnClick func()
}
// Trigger renders the <button> the panel is anchored to and measured against.
func (p *Popover) Trigger(t PopoverTriggerProps, children ...*vdom.VNode) *vdom.VNode {
return p.f.Trigger(FloatingTriggerProps{
Class: t.Class,
Title: t.Title,
OnClick: t.OnClick,
}, children...)
}
// PopoverContentProps configures Content. The Open/Placement fields the old port had
// are gone: open state and placement belong to the Popover.
type PopoverContentProps struct {
Class string
}
// HoverPopover is the hover-driven wrapper. It carries the Tailwind `group`
// marker so HoverPopoverContent can reveal itself on hover purely in CSS.
func HoverPopover(p HoverPopoverProps, children ...*vdom.VNode) *vdom.VNode {
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("group relative inline-block", p.Class))}, children)...)
// Content renders the floating panel, portaled to document.body and positioned by
// measurement once it is in the DOM.
//
// Call it on EVERY render, in the same slot. It no longer returns nil when closed —
// Floating.Panel renders an empty portal instead, so the panel's slot in the parent's
// child list never disappears (the reconciler diffs children by index; a child that
// vanishes shifts every sibling after it).
func (p *Popover) Content(c PopoverContentProps, children ...*vdom.VNode) *vdom.VNode {
return p.f.Panel(FloatingPanelProps{Class: cx(popoverCls, c.Class)}, children...)
}
// HoverPopoverTriggerProps configures HoverPopoverTrigger. OnMouseEnter /
// OnMouseLeave mirror the TSX hover wiring (optional; hover reveal itself is CSS).
type HoverPopoverTriggerProps struct {
OnMouseEnter func()
OnMouseLeave func()
Class string
// pickOffset is pick for a float64 offset: 0 means "unset, use the default".
func pickOffset(v, def float64) float64 {
if v == 0 {
return def
}
// HoverPopoverTrigger is the hover target.
func HoverPopoverTrigger(p HoverPopoverTriggerProps, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", p.Class)}
if p.OnMouseEnter != nil {
mods = append(mods, vdom.On("mouseenter", p.OnMouseEnter))
}
if p.OnMouseLeave != nil {
mods = append(mods, vdom.On("mouseleave", p.OnMouseLeave))
}
return vdom.El("button", kids(mods, children)...)
}
// HoverPopoverContentProps configures HoverPopoverContent.
type HoverPopoverContentProps struct {
Placement string
Class string
OnMouseEnter func()
OnMouseLeave func()
}
// HoverPopoverContent is the hover panel: hidden by default and revealed while
// the surrounding HoverPopover (group) is hovered.
func HoverPopoverContent(p HoverPopoverContentProps, children ...*vdom.VNode) *vdom.VNode {
const reveal = "invisible opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
mods := []vdom.Mod{
vdom.Attr("role", "menu"),
vdom.Attr("data-floating-content", "true"),
vdom.Attr("class", cx("absolute z-[110]", popoverPlacementCls(p.Placement), popoverCls, reveal, p.Class)),
}
if p.OnMouseEnter != nil {
mods = append(mods, vdom.On("mouseenter", p.OnMouseEnter))
}
if p.OnMouseLeave != nil {
mods = append(mods, vdom.On("mouseleave", p.OnMouseLeave))
}
return vdom.El("div", kids(mods, children)...)
return v
}

295
go/webui/position.go Normal file
View File

@@ -0,0 +1,295 @@
package webui
import (
"strings"
"kjol/wasmruntime"
)
// The floating-position engine: given where the trigger is, how big the panel is,
// and how big the viewport is, work out where to put the panel. Pure math over
// structs — no DOM, no browser — so it tests natively and runs identically on the
// server (where it simply never gets called, because nothing is open during SSR).
//
// This replaces the hand-rolled positioning that the TSX kit had in four divergent
// copies (Floating.tsx, Tutorial.tsx, Menu's Submenu, DatePicker's dropdown). It
// deliberately FIXES three flaws in the original rather than reproducing them:
//
// 1. The original shifted on BOTH axes, so a tall panel that did not fit got
// clamped up into its own trigger — covering the thing you clicked. Shift here
// is cross-axis only (what floating-ui does): the main axis is the flip's job.
// 2. The original had no arrow offset, so a shifted panel kept its arrow pinned to
// its own centre and the arrow visibly detached from the trigger. Here the
// arrow tracks the trigger's centre, clamped to stay on the panel.
// 3. The original could not tell a caller how much room there actually was, so a
// long menu near the viewport edge just overflowed. MaxHeight / MaxWidth report
// the space available on the main axis so the panel can scroll instead.
//
// All coordinates are viewport coordinates (getBoundingClientRect + position:fixed
// speak the same language), so there is no scroll compensation anywhere. Adding
// scrollX/scrollY here would be a bug, not a fix.
// Placement values: a base side, optionally with a cross-axis alignment.
const (
PlacementTop = "top"
PlacementTopStart = "top-start"
PlacementTopEnd = "top-end"
PlacementBottom = "bottom"
PlacementBottomStart = "bottom-start"
PlacementBottomEnd = "bottom-end"
PlacementLeft = "left"
PlacementLeftStart = "left-start"
PlacementLeftEnd = "left-end"
PlacementRight = "right"
PlacementRightStart = "right-start"
PlacementRightEnd = "right-end"
)
// PositionOptions tunes ComputePosition. The zero value is not useful; use
// DefaultPositionOptions and override.
type PositionOptions struct {
Placement string // one of the Placement* constants; default bottom-start
Offset float64 // gap between trigger and panel, in px
Flip bool // move to the opposite side when this side does not fit
Shift bool // slide along the cross axis to stay in the viewport
Padding float64 // keep this far from the viewport edge (flip fit-check + shift clamp)
// ArrowSize is the arrow's full width/height in px. Zero means no arrow, and
// ArrowOffset is left at 0.
ArrowSize float64
// ArrowPadding keeps the arrow this far from the panel's corners, so it never
// pokes out of a rounded edge.
ArrowPadding float64
}
// DefaultPositionOptions matches the TSX defaults (bottom-start, 4px offset, flip
// and shift on, 8px viewport padding).
func DefaultPositionOptions() PositionOptions {
return PositionOptions{
Placement: PlacementBottomStart,
Offset: 4,
Flip: true,
Shift: true,
Padding: 8,
ArrowPadding: 4,
}
}
// Position is where to put the panel, in viewport coordinates.
type Position struct {
Top, Left float64
// Placement is the RESOLVED placement — after any flip. This is consumer-visible:
// an arrow must be drawn on the side opposite the resolved base, not the
// requested one, or a flipped tooltip points away from what it describes.
Placement string
// ArrowOffset is the arrow's centre along the panel's cross axis, measured from
// the panel's own top-left: an x for top/bottom placements, a y for left/right.
// Zero when ArrowSize is 0.
ArrowOffset float64
// MaxHeight / MaxWidth are the space available on the main axis at the resolved
// placement, for a panel that should scroll rather than overflow. Only the
// main-axis one is set; the other is 0, meaning unconstrained.
MaxHeight, MaxWidth float64
}
// ComputePosition places `floating` against `trigger` inside `vp`.
//
// The caller must have measured a real, laid-out panel: to know where a panel goes
// you must first know how big it is, which means rendering it (invisibly) and
// measuring it. See Floating.reposition, which does exactly that dance.
func ComputePosition(trigger, floating wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions) Position {
if o.Placement == "" {
o.Placement = PlacementBottomStart
}
base, align := splitPlacement(o.Placement)
top, left := mainAxis(base, trigger, floating, o.Offset)
crossAxis(base, align, trigger, floating, &top, &left)
if o.Flip {
base = flip(base, top, left, trigger, floating, vp, o, &top, &left)
}
if o.Shift {
shiftCrossAxis(base, floating, vp, o.Padding, &top, &left)
}
pos := Position{Top: top, Left: left, Placement: joinPlacement(base, align)}
pos.MaxHeight, pos.MaxWidth = available(base, trigger, vp, o)
if o.ArrowSize > 0 {
pos.ArrowOffset = arrowOffset(base, trigger, floating, top, left, o)
}
return pos
}
// mainAxis positions the panel on the axis it is offset along — the only axis the
// base side controls.
func mainAxis(base string, t, f wasmruntime.Rect, offset float64) (top, left float64) {
switch base {
case "top":
top = t.Top() - f.Height - offset
case "bottom":
top = t.Bottom() + offset
case "left":
left = t.Left() - f.Width - offset
case "right":
left = t.Right() + offset
}
return
}
// crossAxis aligns the panel across the base side: start/center/end.
func crossAxis(base, align string, t, f wasmruntime.Rect, top, left *float64) {
if base == "top" || base == "bottom" {
switch align {
case "start":
*left = t.Left()
case "end":
*left = t.Right() - f.Width
default:
*left = t.CenterX() - f.Width/2
}
return
}
switch align {
case "start":
*top = t.Top()
case "end":
*top = t.Bottom() - f.Height
default:
*top = t.CenterY() - f.Height/2
}
}
// flip moves the panel to the opposite side when it does not fit on this one — but
// only if the opposite side actually fits. If neither side fits we keep the
// original: flipping into an equally bad position just makes it harder to predict.
// The cross-axis alignment is preserved (bottom-start flips to top-start).
func flip(base string, top, left float64, t, f wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions, outTop, outLeft *float64) string {
pad := o.Padding
fits := func(start, size, limit float64) bool { return start >= pad && start+size <= limit-pad }
switch base {
case "bottom":
if !fits(top, f.Height, vp.Height) {
if alt := t.Top() - f.Height - o.Offset; fits(alt, f.Height, vp.Height) {
*outTop = alt
return "top"
}
}
case "top":
if !fits(top, f.Height, vp.Height) {
if alt := t.Bottom() + o.Offset; fits(alt, f.Height, vp.Height) {
*outTop = alt
return "bottom"
}
}
case "right":
if !fits(left, f.Width, vp.Width) {
if alt := t.Left() - f.Width - o.Offset; fits(alt, f.Width, vp.Width) {
*outLeft = alt
return "left"
}
}
case "left":
if !fits(left, f.Width, vp.Width) {
if alt := t.Right() + o.Offset; fits(alt, f.Width, vp.Width) {
*outLeft = alt
return "right"
}
}
}
return base
}
// shiftCrossAxis slides the panel along the CROSS axis to keep it on screen. It
// deliberately does not touch the main axis: clamping there is what let the
// original push a panel on top of its own trigger.
func shiftCrossAxis(base string, f wasmruntime.Rect, vp wasmruntime.Size, pad float64, top, left *float64) {
if base == "top" || base == "bottom" {
*left = clamp(*left, pad, vp.Width-f.Width-pad)
return
}
*top = clamp(*top, pad, vp.Height-f.Height-pad)
}
// available reports the room between the trigger and the viewport edge on the
// resolved side, so a panel too big for it can scroll instead of overflowing.
func available(base string, t wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions) (maxHeight, maxWidth float64) {
gap := o.Offset + o.Padding
switch base {
case "top":
return max(t.Top()-gap, 0), 0
case "bottom":
return max(vp.Height-t.Bottom()-gap, 0), 0
case "left":
return 0, max(t.Left()-gap, 0)
case "right":
return 0, max(vp.Width-t.Right()-gap, 0)
}
return 0, 0
}
// arrowOffset points the arrow at the trigger's centre, in panel-local coordinates,
// clamped so it stays on the panel even when shift has slid the panel away from the
// trigger. When the trigger is entirely off past the panel's edge the arrow sits at
// the clamp limit — visibly at the corner, which is the honest answer.
func arrowOffset(base string, t, f wasmruntime.Rect, top, left float64, o PositionOptions) float64 {
half := o.ArrowSize / 2
if base == "top" || base == "bottom" {
lo := o.ArrowPadding + half
hi := f.Width - o.ArrowPadding - half
return clamp(t.CenterX()-left, lo, max(lo, hi))
}
lo := o.ArrowPadding + half
hi := f.Height - o.ArrowPadding - half
return clamp(t.CenterY()-top, lo, max(lo, hi))
}
func splitPlacement(p string) (base, align string) {
base, align, found := strings.Cut(p, "-")
if !found {
align = "center"
}
switch base {
case "top", "bottom", "left", "right":
default:
base = "bottom"
}
return base, align
}
func joinPlacement(base, align string) string {
if align == "" || align == "center" {
return base
}
return base + "-" + align
}
// OppositeSide is the side an arrow lives on: a panel placed above its trigger has
// its arrow on the bottom edge, pointing down at it.
func OppositeSide(placement string) string {
base, _ := splitPlacement(placement)
switch base {
case "top":
return "bottom"
case "bottom":
return "top"
case "left":
return "right"
default:
return "left"
}
}
func clamp(v, lo, hi float64) float64 {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}

198
go/webui/position_test.go Normal file
View File

@@ -0,0 +1,198 @@
package webui
import (
"testing"
"kjol/wasmruntime"
)
// A 1024x768 viewport with a 100x40 trigger in the middle, and a 200x100 panel.
var (
vp = wasmruntime.Size{Width: 1024, Height: 768}
trigger = wasmruntime.Rect{X: 400, Y: 300, Width: 100, Height: 40}
panel = wasmruntime.Rect{X: 0, Y: 0, Width: 200, Height: 100}
)
func opts(placement string) PositionOptions {
o := DefaultPositionOptions()
o.Placement = placement
return o
}
func TestPlacements(t *testing.T) {
// trigger: left=400 right=500 centerX=450, top=300 bottom=340 centerY=320
// panel: 200x100. offset 4.
cases := []struct {
placement string
top, left float64
}{
{PlacementBottomStart, 344, 400}, // below, left edges aligned
{PlacementBottom, 344, 350}, // below, centred: 450 - 100
{PlacementBottomEnd, 344, 300}, // below, right edges aligned: 500 - 200
{PlacementTopStart, 196, 400}, // above: 300 - 100 - 4
{PlacementTop, 196, 350},
{PlacementTopEnd, 196, 300},
{PlacementRightStart, 300, 504}, // right of: 500 + 4
{PlacementRight, 270, 504}, // right, centred: 320 - 50
{PlacementRightEnd, 240, 504}, // right, bottom-aligned: 340 - 100
{PlacementLeftStart, 300, 196}, // left of: 400 - 200 - 4
{PlacementLeft, 270, 196},
{PlacementLeftEnd, 240, 196},
}
for _, c := range cases {
got := ComputePosition(trigger, panel, vp, opts(c.placement))
if got.Top != c.top || got.Left != c.left {
t.Errorf("%s: got top=%v left=%v, want top=%v left=%v", c.placement, got.Top, got.Left, c.top, c.left)
}
if got.Placement != c.placement {
t.Errorf("%s: resolved placement changed to %s with room to spare", c.placement, got.Placement)
}
}
}
func TestFlipWhenItDoesNotFit(t *testing.T) {
// Trigger near the bottom: a bottom-placed panel would run off, so flip up.
low := wasmruntime.Rect{X: 400, Y: 700, Width: 100, Height: 40}
got := ComputePosition(low, panel, vp, opts(PlacementBottomStart))
if got.Placement != PlacementTopStart {
t.Fatalf("placement = %s, want top-start (should have flipped)", got.Placement)
}
if got.Top != 596 { // 700 - 100 - 4
t.Errorf("top = %v, want 596", got.Top)
}
// The alignment suffix must survive the flip.
if p := ComputePosition(low, panel, vp, opts(PlacementBottomEnd)).Placement; p != PlacementTopEnd {
t.Errorf("bottom-end flipped to %s, want top-end", p)
}
// Trigger near the right edge: right-placed flips to left.
right := wasmruntime.Rect{X: 950, Y: 300, Width: 60, Height: 40}
if p := ComputePosition(right, panel, vp, opts(PlacementRightStart)).Placement; p != PlacementLeftStart {
t.Errorf("placement = %s, want left-start", p)
}
}
// If neither side fits, keep the requested one rather than flipping into an
// equally bad spot.
func TestNoFlipWhenNeitherSideFits(t *testing.T) {
tall := wasmruntime.Rect{X: 0, Y: 0, Width: 200, Height: 700}
mid := wasmruntime.Rect{X: 400, Y: 350, Width: 100, Height: 40}
got := ComputePosition(mid, tall, vp, opts(PlacementBottomStart))
if got.Placement != PlacementBottomStart {
t.Errorf("placement = %s, want bottom-start (no better side available)", got.Placement)
}
}
// The bug we are deliberately fixing: the TSX clamped BOTH axes, so a panel that
// did not fit below got pushed up over the trigger it belonged to. Shift must
// leave the main axis alone.
func TestShiftDoesNotCoverTheTrigger(t *testing.T) {
tall := wasmruntime.Rect{X: 0, Y: 0, Width: 200, Height: 700}
low := wasmruntime.Rect{X: 400, Y: 700, Width: 100, Height: 40}
got := ComputePosition(low, tall, vp, opts(PlacementBottomStart))
if got.Top < low.Bottom() {
t.Errorf("panel top %v is above the trigger's bottom %v — it is covering its own trigger", got.Top, low.Bottom())
}
}
func TestShiftClampsTheCrossAxis(t *testing.T) {
// Trigger hard against the right edge; a bottom-start panel would overflow.
edge := wasmruntime.Rect{X: 980, Y: 300, Width: 40, Height: 40}
got := ComputePosition(edge, panel, vp, opts(PlacementBottomStart))
wantLeft := vp.Width - panel.Width - 8 // 1024 - 200 - padding
if got.Left != wantLeft {
t.Errorf("left = %v, want %v (clamped to the viewport)", got.Left, wantLeft)
}
// And against the left edge.
edge = wasmruntime.Rect{X: 2, Y: 300, Width: 40, Height: 40}
if got := ComputePosition(edge, panel, vp, opts(PlacementBottomEnd)); got.Left != 8 {
t.Errorf("left = %v, want 8 (clamped to the padding)", got.Left)
}
}
// The second flaw we fix: after a shift, the arrow must still point at the trigger.
func TestArrowTracksTheTriggerAfterShift(t *testing.T) {
o := opts(PlacementBottom)
o.ArrowSize = 8
// Unshifted, centred: the arrow sits at the panel's middle.
got := ComputePosition(trigger, panel, vp, o)
if got.ArrowOffset != 100 { // panel is 200 wide
t.Errorf("centred arrow offset = %v, want 100", got.ArrowOffset)
}
// Hard against the right edge, the panel gets shifted left but the trigger did
// not move — so the arrow must slide right to keep pointing at it.
edge := wasmruntime.Rect{X: 980, Y: 300, Width: 40, Height: 40} // centre x = 1000
got = ComputePosition(edge, panel, vp, o)
wantOffset := edge.CenterX() - got.Left // 1000 - 816 = 184
if got.ArrowOffset != wantOffset {
t.Errorf("arrow offset = %v, want %v (pointing at the trigger centre)", got.ArrowOffset, wantOffset)
}
if got.ArrowOffset <= 100 {
t.Error("arrow did not move toward the trigger after the panel was shifted")
}
}
// The arrow must never leave the panel, however far the trigger is.
func TestArrowIsClampedToThePanel(t *testing.T) {
o := opts(PlacementBottomStart)
o.ArrowSize = 8
o.ArrowPadding = 4
far := wasmruntime.Rect{X: 1000, Y: 300, Width: 20, Height: 40}
got := ComputePosition(far, panel, vp, o)
lo, hi := 8.0, panel.Width-8 // padding + half the arrow, at both ends
if got.ArrowOffset < lo || got.ArrowOffset > hi {
t.Errorf("arrow offset %v escaped the panel [%v, %v]", got.ArrowOffset, lo, hi)
}
}
// The third fix: tell the caller how much room there is, so a long menu can scroll
// instead of running off the screen.
func TestAvailableSpace(t *testing.T) {
got := ComputePosition(trigger, panel, vp, opts(PlacementBottomStart))
want := vp.Height - trigger.Bottom() - 4 - 8 // viewport - trigger bottom - offset - padding
if got.MaxHeight != want {
t.Errorf("MaxHeight = %v, want %v", got.MaxHeight, want)
}
if got.MaxWidth != 0 {
t.Errorf("MaxWidth = %v, want 0 (unconstrained on the cross axis)", got.MaxWidth)
}
got = ComputePosition(trigger, panel, vp, opts(PlacementRightStart))
if got.MaxWidth != vp.Width-trigger.Right()-12 {
t.Errorf("MaxWidth = %v, want %v", got.MaxWidth, vp.Width-trigger.Right()-12)
}
if got.MaxHeight != 0 {
t.Errorf("MaxHeight = %v, want 0", got.MaxHeight)
}
}
func TestOppositeSide(t *testing.T) {
for placement, want := range map[string]string{
PlacementTopStart: "bottom",
PlacementBottom: "top",
PlacementLeftEnd: "right",
PlacementRight: "left",
} {
if got := OppositeSide(placement); got != want {
t.Errorf("OppositeSide(%s) = %s, want %s", placement, got, want)
}
}
}
func TestUnknownPlacementFallsBackToBottom(t *testing.T) {
got := ComputePosition(trigger, panel, vp, opts("nonsense"))
if got.Placement != "bottom-center" && got.Placement != "bottom" {
t.Errorf("placement = %s, want a bottom variant", got.Placement)
}
if got.Top != 344 {
t.Errorf("top = %v, want 344 (positioned below)", got.Top)
}
}

View File

@@ -9,7 +9,7 @@ import "kjol/vdom"
// children. That matches the TSX, whose body is `props.children` (rows built with
// AutoTable's TdLeft/TdRight/TdCenter cells). Those cell helpers live in AutoTable
// (not ported here), so callers build rows with the vdom tag builders directly,
// e.g. vdom.El("tr", vdom.El("td", vdom.Attr("class","text-right"), vdom.Text(...))).
// e.g. vdom.Tr(vdom.Td(vdom.Attr("class","text-right"), vdom.Text(...))).
// (CellGrid is the component that models columns + a per-cell Render func.)
//
// The enum values, class maps, and shared class strings that PrettyTable.tsx
@@ -167,11 +167,10 @@ func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children
thCls = cx(thCls, col.HeaderClasses)
innerCls := cx(ptHeaderInnerBase, ptHeaderInnerPos[pos])
headerCells = append(headerCells, vdom.El("th",
vdom.Attr("class", thCls),
vdom.El("div", vdom.Attr("class", ptHeaderContent),
vdom.El("div", vdom.Attr("class", innerCls),
vdom.El("div", vdom.Attr("class", cx("grow text-sm", ptHeaderTextCls[opts.Color])),
headerCells = append(headerCells, vdom.Th(vdom.Attr("class", thCls),
vdom.Div(vdom.Attr("class", ptHeaderContent),
vdom.Div(vdom.Attr("class", innerCls),
vdom.Div(vdom.Attr("class", cx("grow text-sm", ptHeaderTextCls[opts.Color])),
vdom.Text(col.DisplayName),
),
),
@@ -179,16 +178,15 @@ func PrettyTable(columns []PrettyTableColumn, opts PrettyTableOptions, children
))
}
thead := vdom.El("thead",
vdom.Attr("class", "[&_th]:border-b [&_th]:border-neutral-300"),
vdom.El("tr", kids(nil, headerCells)...),
thead := vdom.Thead(vdom.Attr("class", "[&_th]:border-b [&_th]:border-neutral-300"),
vdom.Tr(kids(nil, headerCells)...),
)
tbody := vdom.El("tbody", kids([]vdom.Mod{vdom.Attr("class", prettyTableBodyClass(opts))}, children)...)
tbody := vdom.Tbody(kids([]vdom.Mod{vdom.Attr("class", prettyTableBodyClass(opts))}, children)...)
return vdom.El("div", vdom.Attr("class", containerCls),
vdom.El("div", vdom.Attr("class", ptTblWrapper),
vdom.El("table", vdom.Attr("class", tableCls), thead, tbody),
return vdom.Div(vdom.Attr("class", containerCls),
vdom.Div(vdom.Attr("class", ptTblWrapper),
vdom.Table(vdom.Attr("class", tableCls), thead, tbody),
),
)
}

View File

@@ -40,10 +40,8 @@ func RemoteUpdateFlash(when bool) *vdom.VNode {
if !when {
return nil
}
return vdom.El("div",
vdom.Attr("class", remoteUpdateFlashCls),
vdom.El("svg",
vdom.Attr("viewBox", "0 0 12 12"),
return vdom.Div(vdom.Attr("class", remoteUpdateFlashCls),
vdom.Svg(vdom.Attr("viewBox", "0 0 12 12"),
vdom.Attr("class", "w-2.5 h-2.5 fill-current"),
vdom.Raw(`<circle cx="6" cy="6" r="6"/>`),
),

View File

@@ -51,11 +51,11 @@ func SidebarNav(items []SidebarNavItem, onItemClick func(string), class string)
vdom.On(vdom.EVENT_CLICK, func() { handleClick(id) }),
}
if item.Icon != nil {
btnMods = append(btnMods, vdom.El("span", vdom.Attr("class", sidebarNavIcon), item.Icon))
btnMods = append(btnMods, vdom.Span(vdom.Attr("class", sidebarNavIcon), item.Icon))
}
btnMods = append(btnMods, vdom.Text(item.Label))
liMods := []vdom.Mod{vdom.El("button", btnMods...)}
liMods := []vdom.Mod{vdom.Button(btnMods...)}
if len(item.Children) > 0 {
subListMods := []vdom.Mod{vdom.Attr("class", sidebarNavList)}
@@ -66,20 +66,19 @@ func SidebarNav(items []SidebarNavItem, onItemClick func(string), class string)
vdom.On(vdom.EVENT_CLICK, func() { handleClick(sid) }),
}
if sub.Icon != nil {
subBtnMods = append(subBtnMods, vdom.El("span", vdom.Attr("class", sidebarNavIcon), sub.Icon))
subBtnMods = append(subBtnMods, vdom.Span(vdom.Attr("class", sidebarNavIcon), sub.Icon))
}
subBtnMods = append(subBtnMods, vdom.Text(sub.Label))
subListMods = append(subListMods, vdom.El("li", vdom.El("button", subBtnMods...)))
subListMods = append(subListMods, vdom.Li(vdom.Button(subBtnMods...)))
}
liMods = append(liMods, vdom.El("ul", subListMods...))
liMods = append(liMods, vdom.Ul(subListMods...))
}
listMods = append(listMods, vdom.El("li", liMods...))
listMods = append(listMods, vdom.Li(liMods...))
}
return vdom.El("nav",
vdom.Attr("class", cx(sidebarNavRoot, class)),
vdom.El("ul", listMods...),
return vdom.Nav(vdom.Attr("class", cx(sidebarNavRoot, class)),
vdom.Ul(listMods...),
)
}
@@ -108,7 +107,7 @@ func SidebarLayout(p SidebarLayoutProps, children ...*vdom.VNode) *vdom.VNode {
asideMods := []vdom.Mod{
vdom.Attr("class", sidebarLayoutSidebar),
vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", sticky)}, []*vdom.VNode{p.Sidebar})...),
vdom.Div(kids([]vdom.Mod{vdom.Attr("class", sticky)}, []*vdom.VNode{p.Sidebar})...),
}
if p.Collapsible {
btnMods := []vdom.Mod{
@@ -118,14 +117,13 @@ func SidebarLayout(p SidebarLayoutProps, children ...*vdom.VNode) *vdom.VNode {
if p.OnToggleCollapse != nil {
btnMods = append(btnMods, vdom.On(vdom.EVENT_CLICK, p.OnToggleCollapse))
}
asideMods = append(asideMods, vdom.El("button", btnMods...))
asideMods = append(asideMods, vdom.Button(btnMods...))
}
main := vdom.El("main", kids([]vdom.Mod{vdom.Attr("class", sidebarLayoutMain)}, children)...)
main := vdom.Main(kids([]vdom.Mod{vdom.Attr("class", sidebarLayoutMain)}, children)...)
return vdom.El("div",
vdom.Attr("class", cx(sidebarLayoutRoot, p.Class)),
vdom.El("aside", asideMods...),
return vdom.Div(vdom.Attr("class", cx(sidebarLayoutRoot, p.Class)),
vdom.Aside(asideMods...),
main,
)
}

View File

@@ -113,20 +113,18 @@ func TabGroup(p TabGroupProps) *vdom.VNode {
vdom.Text(item.Title),
}
if item.Badge > 0 {
btn = append(btn, vdom.El("span",
vdom.Attr("class", "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"),
btn = append(btn, vdom.Span(vdom.Attr("class", "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"),
vdom.Text(strconv.Itoa(item.Badge))))
}
header = append(header, vdom.El("button", btn...))
header = append(header, vdom.Button(btn...))
}
if p.Actions != nil {
header = append(header, vdom.El("div",
vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-neutral-200 flex items-center justify-end pb-1"),
vdom.El("div", vdom.Attr("class", "flex items-center min-w-0"), p.Actions),
header = append(header, vdom.Div(vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-neutral-200 flex items-center justify-end pb-1"),
vdom.Div(vdom.Attr("class", "flex items-center min-w-0"), p.Actions),
))
}
root := []vdom.Mod{vdom.Attr("class", rootCls), vdom.El("div", header...)}
root := []vdom.Mod{vdom.Attr("class", rootCls), vdom.Div(header...)}
hasInlinePanels := false
for _, item := range p.Items {
@@ -140,8 +138,8 @@ func TabGroup(p TabGroupProps) *vdom.VNode {
if item.Content == nil {
continue
}
root = append(root, vdom.El("div", vdom.Attr("class", panelCls(i)), item.Content))
root = append(root, vdom.Div(vdom.Attr("class", panelCls(i)), item.Content))
}
}
return vdom.El("div", root...)
return vdom.Div(root...)
}

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

View File

@@ -0,0 +1,83 @@
package webui
import (
"strings"
"testing"
)
// ---- Toaster ----
// The whole point of Toaster over ToastProvider: it removes toasts by itself. On
// the server there is no clock (SetTimeout is a no-op), so nothing is scheduled —
// but the toast still renders, which is what SSR should show.
func TestToasterPushAndDismiss(t *testing.T) {
tr := NewToaster(ToasterOptions{})
id := tr.Success("Saved.")
if len(tr.Toasts()) != 1 {
t.Fatalf("Push added %d toasts, want 1", len(tr.Toasts()))
}
got := tr.Toasts()[0]
if got.ID != id || got.Type != ToastSuccess || got.Message != "Saved." {
t.Errorf("unexpected toast: %+v", got)
}
// An unset duration must become the default, NOT "forever": a toast that never
// leaves is how a notification area silently fills up.
if got.Duration != DefaultToastDuration {
t.Errorf("Duration = %d, want the default %d", got.Duration, DefaultToastDuration)
}
if !got.ShowProgress {
t.Error("an auto-dismissing toast should show its countdown")
}
tr.Dismiss(id)
if len(tr.Toasts()) != 0 {
t.Errorf("Dismiss left %d toasts", len(tr.Toasts()))
}
}
func TestToasterSticky(t *testing.T) {
tr := NewToaster(ToasterOptions{})
tr.Push(Toast{Message: "stays", Duration: ToastSticky})
got := tr.Toasts()[0]
if got.Duration != ToastSticky {
t.Errorf("Duration = %d, want ToastSticky", got.Duration)
}
// No countdown bar on something that is not counting down.
if got.ShowProgress {
t.Error("a sticky toast should not show a progress bar")
}
}
func TestToasterIDsAreUnique(t *testing.T) {
tr := NewToaster(ToasterOptions{})
seen := map[string]bool{}
for range 5 {
id := tr.Info("x")
if seen[id] {
t.Fatalf("duplicate toast ID %q", id)
}
seen[id] = true
}
tr.Clear()
if len(tr.Toasts()) != 0 {
t.Error("Clear left toasts behind")
}
}
func TestToasterRendersCountdownBar(t *testing.T) {
tr := NewToaster(ToasterOptions{})
tr.Success("Saved.")
html := renderNode(tr.Render())
// The bar is DECLARED at full width; the countdown is an imperative transition
// from there to 0. If the declared width ever stopped being 100%, the bar would
// start empty and the animation would be invisible.
if !strings.Contains(html, "width:100%") {
t.Errorf("countdown bar not declared at full width:\n%s", html)
}
if !strings.Contains(html, "circle-check") && !strings.Contains(html, "<path") {
t.Error("the toast's icon did not render")
}
}

View File

@@ -36,13 +36,13 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
vdom.Attr("aria-checked", ariaChecked),
vdom.Attr("class", trackCls),
vdom.On(vdom.EVENT_CLICK, toggle),
vdom.El("span", vdom.Attr("class", knobCls)),
vdom.Span(vdom.Attr("class", knobCls)),
}
if disabled {
btnMods = append(btnMods, vdom.Attr("disabled", "disabled"))
}
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-2", class)), vdom.El("button", btnMods...)}
mods := []vdom.Mod{vdom.Attr("class", cx("flex items-center gap-2", class)), vdom.Button(btnMods...)}
if label != "" || description != "" {
text := []vdom.Mod{vdom.Attr("class", "flex flex-col leading-tight")}
@@ -51,15 +51,14 @@ func ToggleSwitch(checked bool, onChange func(bool), label, description string,
if disabled {
labelColor = "text-neutral-400"
}
text = append(text, vdom.El("span",
vdom.Attr("class", cx("text-sm select-none", labelColor)),
text = append(text, vdom.Span(vdom.Attr("class", cx("text-sm select-none", labelColor)),
vdom.On(vdom.EVENT_CLICK, toggle),
vdom.Text(label)))
}
if description != "" {
text = append(text, vdom.El("span", vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(description)))
text = append(text, vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(description)))
}
mods = append(mods, vdom.El("div", text...))
mods = append(mods, vdom.Div(text...))
}
return vdom.El("div", mods...)
return vdom.Div(mods...)
}

View File

@@ -1,101 +1,222 @@
// Port of web/kit/Tooltips.tsx.
// Port of web/uikit/Tooltips.tsx, rebuilt on the Floating controller (floating.go).
//
// NOTE: the TSX builds tooltips on Floating.tsx (floating-ui-style measured
// positioning with flip/shift, a Portal, a FloatingContext, and hover/focus
// open-delay timers). The neutral runtime has none of that, so this port keeps
// the API + Tailwind + arrow markup and instead reveals the bubble purely in CSS
// via the Tailwind `group` marker (group-hover / group-focus-within). Placement
// is approximated with static absolute utility classes (no flip/shift and no
// measured coordinates), the numeric offset collapses to the fixed ~8px m*-2 gap
// (the TSX default), and the open delay is dropped.
// The first Go port degraded the tooltip into a pure-CSS `group-hover` reveal:
// static placement classes instead of measurement, no flip/shift, no portal, no
// open delay, no close grace period, an arrow drawn from the REQUESTED placement
// (so it pointed the wrong way after a flip — except it could never flip), and a
// `tabindex="0"` on every wrapper, which put every tooltip in the tab order. All of
// that is restored/fixed here on top of Floating: the bubble is portaled to
// document.body, measured, flipped and shifted, opens after 200ms of hover, and
// stays up for a grace period so the cursor can cross the gap and select text in it.
//
// Deliberate deviations from the TSX, and why:
//
// - The arrow is Floating.Arrow (a rotated square pinned to the panel's edge whose
// offset along that edge is written imperatively) rather than the TSX's
// border-triangle utility classes. Those classes pin the arrow to a fixed
// fraction of the PANEL (`left-1/2`), so as soon as shift slides the panel away
// from its trigger the arrow visibly detaches from it. Floating.Arrow tracks the
// trigger, and takes its SIDE from the RESOLVED placement, so it still points at
// the trigger after a flip.
// - One close delay (100ms — the TSX's trigger-leave value) covers both
// trigger-leave and panel-leave; the TSX used 50ms for panel-leave. Floating owns
// the hover bridge and has a single HoverCloseDelay, and 100ms is the more
// forgiving of the two, which is the whole point of the grace period.
// - The trigger element is built here instead of with Floating.Trigger. A tooltip
// wraps arbitrary content — usually a <button> — so the trigger can be neither a
// <button> itself (nested buttons are invalid) nor carry Floating.Trigger's
// Enter/Space keydown toggle: that handler sits on the wrapper, DOM keydown
// bubbles up from the wrapped button, and its preventDefault would swallow the
// button's own Enter activation. The hover/close TIMERS still come from Floating
// (hoverEnter / hoverLeave), and the panel keeps Floating's half of the bridge —
// nothing about the bridge is reimplemented here.
// - Tooltips are Standalone: they never join the single-open manager. In the TSX a
// tooltip evicted whatever popover or menu was open — including the one its own
// trigger lived inside, which yanked the trigger out from under the cursor.
package webui
import "kjol/vdom"
const tooltipCls = "bg-neutral-800 text-white text-sm px-2.5 py-1.5 rounded-default shadow-lg max-w-80 relative"
// tooltipArrowCls returns the arrow classes for a tooltip on the given base side
// (the arrow points back toward the trigger). Ported verbatim from arrowCls.
func tooltipArrowCls(base string) string {
const common = "absolute w-0 h-0"
switch base {
case "top":
return common + " -bottom-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-neutral-800"
case "bottom":
return common + " -top-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[6px] border-b-neutral-800"
case "left":
return common + " -right-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-l-[6px] border-l-neutral-800"
case "right":
return common + " -left-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-neutral-800"
default:
return common
}
}
// Defaults from the TSX: TOOLTIP_DEFAULT_OFFSET = 8, delay ?? 200, close after 100ms.
const (
tooltipOffset = 8
tooltipOpenDelay = 200
tooltipCloseDelay = 100
// The rotated square is ~11px across the diagonal and pokes ~6px out of the
// panel — the same silhouette as the TSX's 6px border triangle.
tooltipArrowSize = 8
)
// tooltipPlacementCls positions the bubble relative to the trigger wrapper.
func tooltipPlacementCls(placement string) string {
switch placement {
case "bottom":
return "top-full left-1/2 -translate-x-1/2 mt-2"
case "left":
return "right-full top-1/2 -translate-y-1/2 mr-2"
case "right":
return "left-full top-1/2 -translate-y-1/2 ml-2"
default: // "top"
return "bottom-full left-1/2 -translate-x-1/2 mb-2"
}
}
// Tooltip trigger modes (TooltipProps.Trigger).
const (
TooltipTriggerHover = "hover"
TooltipTriggerFocus = "focus"
)
// tooltipRevealCls returns the CSS reveal classes for the given trigger mode.
func tooltipRevealCls(trigger string) string {
if trigger == "focus" {
return "invisible opacity-0 transition-opacity group-focus-within:visible group-focus-within:opacity-100"
}
return "invisible opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
}
// TooltipProps configures Tooltip. Trigger is "hover" (default) or "focus";
// Placement is "top" (default), "bottom", "left" or "right".
// TooltipProps configures NewTooltip.
type TooltipProps struct {
Content *vdom.VNode
// Trigger is TooltipTriggerHover (default) or TooltipTriggerFocus. A focus
// tooltip does not open on hover; it opens when anything inside it takes focus.
// Both are keyboard-reachable — focusin/focusout bubble out of the wrapped
// element, so no tabindex is needed on the wrapper.
Trigger string
// Placement defaults to "top". Any Placement* constant works; the resolved
// placement may differ after a flip (the arrow follows it).
Placement string
// Offset is the gap between trigger and bubble in px; 0 means the TSX default, 8.
Offset float64
// Delay is the hover open delay in ms; 0 means the TSX default, 200. Pass a
// negative value for no delay (Go cannot tell an unset 0 from a deliberate one).
Delay int
// Class goes on the trigger wrapper, not the bubble.
Class string
}
// Tooltip wraps children (the trigger) and shows Content in a floating bubble on
// hover (or keyboard focus when Trigger is "focus").
func Tooltip(p TooltipProps, children ...*vdom.VNode) *vdom.VNode {
placement := pick(p.Placement, "top")
bubble := vdom.El("div",
vdom.Attr("role", "tooltip"),
vdom.Attr("data-floating-content", "true"),
vdom.Attr("class", cx("absolute z-[110]", tooltipPlacementCls(placement), tooltipCls, tooltipRevealCls(p.Trigger))),
)
if p.Content != nil {
bubble.Children = append(bubble.Children, p.Content)
// Tooltip is a live tooltip: a Floating plus the trigger wrapper around it.
//
// It is a CONTROLLER, not a render function, because a Floating holds refs, timers
// and open state that must survive across renders. Build it once, next to your
// signals, and call Render inside the render closure:
//
// tip := webui.NewHoverTooltip(webui.PlacementTop, "")
// return func() *vdom.VNode {
// return tip.Render(vdom.Text("Deletes the row"), webui.Button(...))
// }
//
// (This is the API change from the old free function `Tooltip(props, children...)`,
// which could not hold state and so could only ever be CSS.)
type Tooltip struct {
f *Floating
trigger string
class string
}
// NewTooltip creates a tooltip controller. Call it once, OUTSIDE the render
// function.
func NewTooltip(p TooltipProps) *Tooltip {
delay := p.Delay
switch {
case delay == 0:
delay = tooltipOpenDelay
case delay < 0:
delay = 0
}
offset := p.Offset
if offset == 0 {
offset = tooltipOffset
}
trigger := pick(p.Trigger, TooltipTriggerHover)
return &Tooltip{
trigger: trigger,
class: p.Class,
f: NewFloating(FloatingOptions{
Placement: pick(p.Placement, PlacementTop),
Offset: offset,
ArrowSize: tooltipArrowSize,
ArrowPadding: 6,
// OpenOnHover is set for BOTH modes: it is what gives the PANEL its half
// of the hover bridge (enter cancels the pending close, leave reschedules
// it), which is what lets the cursor land on the bubble and select text in
// it. The trigger's half is wired in Render — a focus tooltip wires only
// focusin/focusout there, so it still does not open on hover.
OpenOnHover: true,
HoverDelay: delay,
HoverCloseDelay: tooltipCloseDelay,
// A tooltip is not a dialog: it must not evict the menu or popover it is
// hovered inside (which would take its own trigger with it).
Standalone: true,
}),
}
}
// NewHoverTooltip is NewTooltip in hover mode (the TSX's default variant).
// Placement defaults to "top"; class goes on the trigger wrapper.
func NewHoverTooltip(placement, class string) *Tooltip {
return NewTooltip(TooltipProps{Trigger: TooltipTriggerHover, Placement: placement, Class: class})
}
// NewFocusTooltip is NewTooltip in focus mode: it opens when the wrapped content
// takes focus, not on hover.
func NewFocusTooltip(placement, class string) *Tooltip {
return NewTooltip(TooltipProps{Trigger: TooltipTriggerFocus, Placement: placement, Class: class})
}
// Floating exposes the underlying controller (placement, open state, Dispose...).
func (t *Tooltip) Floating() *Floating { return t.f }
// IsOpen reports whether the bubble is showing.
func (t *Tooltip) IsOpen() bool { return t.f.IsOpen() }
// Hide closes the bubble now — e.g. when the thing it describes is removed.
func (t *Tooltip) Hide() { t.f.Hide() }
// Dispose closes the bubble and drops every listener and timer it owns.
func (t *Tooltip) Dispose() { t.f.Dispose() }
// Render draws the trigger wrapper around children, plus the (portaled) bubble
// holding content.
//
// content is passed per render rather than stored on the props so it can depend on
// signals — and so the same *VNode is never handed to the reconciler twice.
//
// Call this on EVERY render, in the same slot: when the tooltip is closed the panel
// is an empty portal, not a missing child, which is what keeps the sibling indexes
// the reconciler diffs by stable.
func (t *Tooltip) Render(content *vdom.VNode, children ...*vdom.VNode) *vdom.VNode {
// The TSX wraps a hover trigger in `<span style="display:inline-block">` and a
// focus trigger in a plain `<div>`. Same here — the wrapper is what gets measured,
// so it has to shrink-wrap the trigger rather than stretch across the line box.
tag, class := "span", cx("inline-block", t.class)
if t.trigger == TooltipTriggerFocus {
tag, class = "div", t.class
}
bubble.Children = append(bubble.Children, vdom.El("div", vdom.Attr("class", tooltipArrowCls(placement))))
// The wrapper carries `group` + `tabindex` so both hover and keyboard-focus
// reveal work in CSS. inline-block mirrors the TSX trigger's display.
mods := []vdom.Mod{
vdom.Attr("class", cx("group relative inline-block", p.Class)),
vdom.Attr("tabindex", "0"),
vdom.WithRef(t.f.triggerRef),
vdom.Attr("class", class),
}
if t.trigger == TooltipTriggerHover {
// The controller's timers, not ours: hoverEnter opens after Delay, hoverLeave
// schedules the close that the panel's own mouseenter can cancel.
mods = append(mods,
vdom.On(vdom.EVENT_MOUSEENTER, t.f.hoverEnter),
vdom.On(vdom.EVENT_MOUSELEAVE, t.f.hoverLeave),
)
}
// focusin/focusout BUBBLE (focus/blur do not), so focusing the wrapped button or
// input opens the tooltip without the wrapper needing a tabindex of its own —
// which is how the keyboard reaches it now that the old port's unconditional
// tabindex="0" (and the tab-order pollution it caused) is gone.
mods = append(mods,
vdom.On(vdom.EVENT_FOCUSIN, t.f.Show),
// Not Hide: leaving on the grace timer means tabbing away, or blurring to grab
// the bubble with the mouse, does not snatch the text out from under you.
vdom.On(vdom.EVENT_FOCUSOUT, t.f.hoverLeave),
)
mods = kids(mods, children)
mods = append(mods, bubble)
return vdom.El("span", mods...)
mods = append(mods, t.panel(content))
return vdom.El(tag, mods...)
}
// HoverTooltip is Tooltip with hover reveal (the TSX default variant).
func HoverTooltip(content *vdom.VNode, placement, class string, children ...*vdom.VNode) *vdom.VNode {
return Tooltip(TooltipProps{Content: content, Trigger: "hover", Placement: placement, Class: class}, children...)
}
// FocusTooltip is Tooltip revealed on keyboard focus.
func FocusTooltip(content *vdom.VNode, placement, class string, children ...*vdom.VNode) *vdom.VNode {
return Tooltip(TooltipProps{Content: content, Trigger: "focus", Placement: placement, Class: class}, children...)
// panel is the bubble: role=tooltip, portaled, with the arrow last so it paints over
// the bubble's own background.
func (t *Tooltip) panel(content *vdom.VNode) *vdom.VNode {
return t.f.Panel(
FloatingPanelProps{Role: "tooltip", Class: tooltipCls},
content,
// The arrow's side comes from the RESOLVED placement (Floating.Arrow reads
// it), so a tooltip that flipped from top to bottom points UP at its trigger
// instead of down at nothing — the bug in the old tooltipArrowCls, which was
// handed the requested placement.
t.f.Arrow("bg-neutral-800"),
)
}

View File

@@ -4,138 +4,748 @@ import (
"strconv"
"kjol/vdom"
"kjol/wasmruntime"
)
// Port of web/kit/Tutorial.tsx.
// Port of web/uikit/Tutorial.tsx — a guided tour: it dims the page, cuts a
// spotlight hole around the step's target element, and floats a popover card with
// step navigation.
//
// Tutorial is a guided-tour / coachmark overlay: it dims the page, spotlights a
// target element, and floats a popover card with step navigation. The Solid
// source leans heavily on browser-only capabilities the neutral vdom runtime
// does not have (DOM measurement via getBoundingClientRect, portals, effects,
// timers, scroll/resize listeners, window sizing). What is ported vs. dropped:
// Tutorial is an ENGINE, not a pure function. It owns everything the tour needs to
// know that a render cannot: where the target is (a measurement), how big the card
// is (another measurement), which frame the animation is on, and which timers are in
// flight. Create it ONCE, outside your render function:
//
// - NOTE: calculatePopoverPosition — the viewport-aware placement math that
// anchors and flips the popover around the target rect — is DROPPED. The
// popover is statically centered with Tailwind instead of computed coords.
// - NOTE: SpotlightOverlay's measured cutout (a giant box-shadow ring drawn
// around the target's DOMRect) is APPROXIMATED by a plain dimmed backdrop,
// which is the source's own no-target fallback.
// - NOTE: PopoverArrow (the little triangle pointing at the target) is DROPPED,
// since there is no target position to point at.
// - NOTE: the fade/scale/slide transitions and the requestAnimationFrame /
// setTimeout choreography are DROPPED; the card renders in its final state.
// - NOTE: Solid context (TutorialProvider/useTutorial) + signals collapse to
// plain props — the caller owns the active flag, the current-step index, and
// the next/prev/close callbacks (read at the call site, as per kit convention).
// - NOTE: per-step onEnter/onLeave lifecycle hooks (effect-driven) are DROPPED,
// as is the string/function/null target union — Target here is a plain CSS
// selector kept for reference only (nothing measures or scrolls to it).
// - NOTE: SpotlightPadding, Placement, and Offset are retained for API parity
// but are unused, because there is no positioning/spotlight to apply them to.
// tour := webui.NewTutorial(webui.TutorialOptions{Steps: []webui.TutorialStep{
// {Title: "Filters", Target: "#filters", Placement: webui.PlacementRight,
// Content: func() *vdom.VNode { return Text("Narrow the table down here.") }},
// {Title: "Export", Target: "#export-btn",
// Content: func() *vdom.VNode { return Text("…then export what is left.") }},
// }})
//
// return func() *vdom.VNode {
// return Div(page(), tour.StartButton(0, ""), tour.Render())
// }
//
// How a step is staged, and why in that order:
//
// 1. The step's Target is a CSS selector — QuerySelector finds an element the tour
// does not own — resolved after a short delay, so a route transition or a layout
// settling does not hand us a stale box.
// 2. The target is measured and smooth-scrolled to the centre of the viewport.
// 3. The spotlight is a `position: fixed` box sized to that rect plus padding, with
// `box-shadow: 0 0 0 9999px rgba(0,0,0,0.5)`: the enormous spread IS the page
// dimming, and the box is the hole in it. Because its geometry is written with
// SetStyle onto an element that declares `transition: all 100ms`, the hole
// ANIMATES from one target to the next instead of teleporting.
// 4. The card is placed with ComputePosition (the same engine the floating layer
// uses — no hand-rolled second copy) and revealed. It re-positions on scroll
// (capture, because scroll does not bubble) and resize.
//
// Nothing here is browser-only in the Go sense: every host call stubs out natively,
// so on the server the tour is simply never active, Render emits an empty portal, and
// no measurement, listener or timer exists. There is no black screen to SSR.
type Tutorial struct {
opts TutorialOptions
// TutorialStep is one stop in a guided tour. Content is the body VNode (the TSX
// JSXElement). Target is the CSS selector of the element the step would spotlight
// (see file NOTE — not measured here). Placement/Offset are kept for API parity
// but are not applied.
type TutorialStep struct {
Title string
Content *vdom.VNode
Target string
Placement string
Offset int
active *vdom.Signal[bool]
// index is the live step; displayed is the step currently PAINTED. They differ
// only during a step transition, which double-buffers the card: the content is
// swapped at the midpoint of the cross-fade.
index *vdom.Signal[int]
displayed *vdom.Signal[int]
// placement is the RESOLVED placement (after ComputePosition may have flipped it).
// The arrow's side is a class/style decision, so it has to go through a render —
// hence a signal, written only when it actually changes.
placement *vdom.Signal[string]
showArrow *vdom.Signal[bool]
// spotlight is true when the step's target resolved AND has been measured; false
// means the flat-dim fallback (also the state a step with no target renders, and
// the only state the server can produce).
spotlight *vdom.Signal[bool]
popoverRef *vdom.Ref
contentRef *vdom.Ref
overlayRef *vdom.Ref
// Everything below is plain state, deliberately NOT signals: it feeds imperative
// style writes that run on every scroll frame, and a signal write there would
// re-render the whole app per frame.
rect wasmruntime.Rect
radius float64
positioned bool
transitioning bool
revealed bool // the overlay's dim has faded in on the current overlay node
firstAppearance bool
unsubs []wasmruntime.Unsub
targetTimer int
stepTimer int
arrowTimer int
}
// TutorialProps drives the tour overlay. CurrentStep is the active index as a
// plain value (the state that was a Solid signal now lives with the caller).
// Active gates whether the overlay renders at all. OnNext/OnPrev advance the
// tour; OnClose ends it (shared by the backdrop click, the close button, and the
// Finish button on the last step).
type TutorialProps struct {
// TutorialStep is one stop on the tour.
type TutorialStep struct {
Title string
// Content is called on every render, so content that reads signals stays live.
Content func() *vdom.VNode
// Target is a CSS selector for the element to spotlight. Empty means the step has
// no anchor: the page dims flat and the card centres in the viewport.
Target string
// Placement is one of the Placement* constants; default bottom. It may be flipped
// if the card does not fit on that side.
Placement string
// Offset is the gap between target and card in px; default 16.
Offset float64
OnEnter func()
OnLeave func()
}
// TutorialOptions configures a Tutorial. The zero value is usable (no steps).
type TutorialOptions struct {
Steps []TutorialStep
CurrentStep int
Active bool
SpotlightPadding int
OnNext func()
OnPrev func()
OnClose func()
// SpotlightPadding is how far the cutout is inflated past the target; default 8.
SpotlightPadding float64
// OnEnd fires after the tour ends, however it ended (Finish, ✕, Escape, a click
// on the dim, or End()).
OnEnd func()
// Class is appended to the popover card.
Class string
}
// tutorialOverlay is the dimmed backdrop. NOTE: this approximates SpotlightOverlay
// without the measured spotlight cutout (see file NOTE); clicking it ends the tour.
func tutorialOverlay(p TutorialProps) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", "fixed inset-0 bg-black/50 z-150")}
if p.OnClose != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
}
return vdom.El("div", mods...)
// TSX constants: _TUTORIAL_ANIMATION_DURATION, the popover's default offset, and the
// beat we wait before measuring a target (long enough for a route transition or a
// layout to settle, short enough not to be seen).
const (
tutorialAnimMS = 100
tutorialHalfMS = tutorialAnimMS / 2
tutorialSettleMS = 50
tutorialTargetMS = 50
tutorialDefaultPad = 8.0
tutorialDefaultOff = 16.0
tutorialViewportPad = 16.0
// tutorialFallbackRadius is the TSX's initial borderRadius, used when
// --radius-default is unreadable (and on the server).
tutorialFallbackRadius = 3.2
)
// Declared styles. Each is CONSTANT across renders, which is what lets the imperative
// writes below survive reconciliation (the reconciler only re-sets an attribute whose
// declared value changed). They encode the pre-measurement state — the state the
// server renders and the browser paints for one frame before the first measurement.
const (
// The card: laid out (so it can be measured) but not painted, and faded/scaled
// down ready for the enter animation. No top/left transition yet — the first
// appearance must not slide in from 0,0.
tutorialPopoverStyle = "top:0;left:0;visibility:hidden;opacity:0;transform:scale(0.95);transition:opacity 100ms cubic-bezier(0.4, 0, 0.2, 1), transform 100ms cubic-bezier(0.4, 0, 0.2, 1)"
// From the first step change on, top/left transition too, so the card SLIDES
// between targets. Written imperatively when the first transition starts.
tutorialPopoverSlide = "opacity 100ms cubic-bezier(0.4, 0, 0.2, 1), transform 100ms cubic-bezier(0.4, 0, 0.2, 1), top 100ms cubic-bezier(0.4, 0, 0.2, 1), left 100ms cubic-bezier(0.4, 0, 0.2, 1)"
// The card's inner content, cross-faded on a step change (out, swap, in).
tutorialContentStyle = "opacity:1;transition:opacity 50ms ease-out"
// The spotlight: a zero-size box with a transparent 9999px shadow. The geometry
// and the shadow's alpha are both written imperatively; `transition: all` is what
// animates the hole between targets.
tutorialSpotlightStyle = "left:0;top:0;width:0;height:0;box-shadow:0 0 0 9999px rgba(0, 0, 0, 0);transition:all 100ms cubic-bezier(0.4, 0, 0.2, 1)"
tutorialSpotlightShadow = "0 0 0 9999px rgba(0, 0, 0, 0.5)"
// The no-target fallback: a flat dim that fades in.
tutorialBackdropStyle = "opacity:0;transition:opacity 100ms ease-out"
)
// Tailwind classes, verbatim from the TSX.
const (
tutorialPopoverClass = "fixed z-200 bg-white rounded-default shadow-lg border border-neutral-200 max-w-sm"
tutorialSpotlightClass = "fixed z-150 pointer-events-none rounded-default"
tutorialCatcherClass = "fixed inset-0 -z-10 cursor-pointer"
tutorialBackdropClass = "fixed inset-0 bg-black/50 z-150"
tutorialArrowClass = "absolute w-0 h-0"
)
// The arrow is two stacked CSS triangles: a 9px one in the border colour, and an 8px
// white one on top of it, which is what makes a bordered arrow out of two borders.
// Both are keyed by the RESOLVED base placement and hang off the OPPOSITE edge of the
// card — a card placed above its target carries its arrow on its bottom edge, pointing
// down at it.
var tutorialArrowOuter = map[string]string{
"top": "bottom:-9px;left:50%;transform:translateX(-50%);border-left:9px solid transparent;border-right:9px solid transparent;border-top:9px solid #e5e5e5;",
"bottom": "top:-9px;left:50%;transform:translateX(-50%);border-left:9px solid transparent;border-right:9px solid transparent;border-bottom:9px solid #e5e5e5;",
"left": "right:-9px;top:50%;transform:translateY(-50%);border-top:9px solid transparent;border-bottom:9px solid transparent;border-left:9px solid #e5e5e5;",
"right": "left:-9px;top:50%;transform:translateY(-50%);border-top:9px solid transparent;border-bottom:9px solid transparent;border-right:9px solid #e5e5e5;",
}
// tutorialPopover renders the tooltip card (title + "X of N" + close, body,
// prev/next controls, and the step dots) for step idx. NOTE: it is statically
// centered rather than anchored to the target (see file NOTE).
func tutorialPopover(p TutorialProps, idx int) *vdom.VNode {
step := p.Steps[idx]
total := len(p.Steps)
var tutorialArrowInner = map[string]string{
"top": "bottom:-8px;left:50%;transform:translateX(-50%);border-left:8px solid transparent;border-right:8px solid transparent;border-top:8px solid white;",
"bottom": "top:-8px;left:50%;transform:translateX(-50%);border-left:8px solid transparent;border-right:8px solid transparent;border-bottom:8px solid white;",
"left": "right:-8px;top:50%;transform:translateY(-50%);border-top:8px solid transparent;border-bottom:8px solid transparent;border-left:8px solid white;",
"right": "left:-8px;top:50%;transform:translateY(-50%);border-top:8px solid transparent;border-bottom:8px solid transparent;border-right:8px solid white;",
}
// Header: optional title + step counter, and a close button.
const tutorialArrowTransition = "width:0;height:0;transition:all 100ms cubic-bezier(0.4, 0, 0.2, 1);"
// NewTutorial creates a tour engine. Call it once, outside your render function.
func NewTutorial(o TutorialOptions) *Tutorial {
if o.SpotlightPadding == 0 {
o.SpotlightPadding = tutorialDefaultPad
}
return &Tutorial{
opts: o,
active: vdom.NewSignal(false),
index: vdom.NewSignal(0),
displayed: vdom.NewSignal(0),
placement: vdom.NewSignal(PlacementBottom),
showArrow: vdom.NewSignal(false),
spotlight: vdom.NewSignal(false),
popoverRef: vdom.NewRef(),
contentRef: vdom.NewRef(),
overlayRef: vdom.NewRef(),
firstAppearance: true,
}
}
// SetSteps replaces the tour's steps (for a tour whose content depends on data that
// arrives later). Ends the tour if it is running.
func (t *Tutorial) SetSteps(steps []TutorialStep) {
if t.active.Get() {
t.End()
}
t.opts.Steps = steps
}
// IsActive, CurrentStep and TotalSteps are the tour's public state (the TSX's
// TutorialContextValue). Safe to read during render.
func (t *Tutorial) IsActive() bool { return t.active.Get() }
func (t *Tutorial) CurrentStep() int { return t.index.Get() }
func (t *Tutorial) TotalSteps() int { return len(t.opts.Steps) }
func (t *Tutorial) step(i int) *TutorialStep {
if i < 0 || i >= len(t.opts.Steps) {
return nil
}
return &t.opts.Steps[i]
}
// ---- driving the tour ----
// Start begins the tour at stepIndex (clamped). Starting an already-running tour just
// jumps to that step.
func (t *Tutorial) Start(stepIndex int) {
if len(t.opts.Steps) == 0 {
return
}
i := clampIndex(stepIndex, len(t.opts.Steps))
if t.active.Get() {
t.GoTo(i)
return
}
t.rect = wasmruntime.Rect{}
t.positioned = false
t.transitioning = false
t.revealed = false
t.firstAppearance = true
t.index.Set(i)
t.displayed.Set(i)
t.placement.Set(PlacementBottom)
t.showArrow.Set(false)
t.spotlight.Set(false)
t.active.Set(true)
if s := t.step(i); s != nil && s.OnEnter != nil {
s.OnEnter()
}
// The overlay and card do not exist yet — the signal writes only *scheduled* a
// render. Measure and animate once they do.
wasmruntime.AfterRender(t.mounted)
}
// End stops the tour.
func (t *Tutorial) End() {
if !t.active.Get() {
return
}
if s := t.step(t.index.Get()); s != nil && s.OnLeave != nil {
s.OnLeave()
}
t.teardown()
t.rect = wasmruntime.Rect{}
t.positioned = false
t.transitioning = false
t.revealed = false
t.firstAppearance = true
t.active.Set(false)
t.index.Set(0)
t.displayed.Set(0)
t.showArrow.Set(false)
t.spotlight.Set(false)
if t.opts.OnEnd != nil {
t.opts.OnEnd()
}
}
// Next advances, or ends the tour on the last step.
func (t *Tutorial) Next() {
if !t.active.Get() {
return
}
if i := t.index.Get(); i < len(t.opts.Steps)-1 {
t.GoTo(i + 1)
return
}
t.End()
}
// Previous steps back (a no-op on the first step).
func (t *Tutorial) Previous() {
if !t.active.Get() {
return
}
if i := t.index.Get(); i > 0 {
t.GoTo(i - 1)
}
}
// GoTo jumps to a step, running the leave/enter hooks and the transition choreography.
//
// The ORDER here is load-bearing, and it is the TSX's:
//
// 1. The target is re-resolved on a timer FIRST, so the new rect is in hand by the
// time the card asks where to go.
// 2. The card fades its content out, swaps the step at the midpoint of the fade,
// re-positions (now with top/left transitions on, so it slides), fades back in,
// and only then re-shows the arrow.
//
// While all that is in flight, `transitioning` suppresses scroll/resize
// re-positioning: the card is mid-slide and must not be yanked by a scroll frame
// (including the smooth scroll this very step just started).
func (t *Tutorial) GoTo(stepIndex int) {
if !t.active.Get() || len(t.opts.Steps) == 0 {
return
}
i := clampIndex(stepIndex, len(t.opts.Steps))
cur := t.index.Get()
if i == cur {
return
}
if s := t.step(cur); s != nil && s.OnLeave != nil {
s.OnLeave()
}
t.index.Set(i)
if s := t.step(i); s != nil && s.OnEnter != nil {
s.OnEnter()
}
// 1. the new target.
wasmruntime.ClearTimeout(t.targetTimer)
t.targetTimer = wasmruntime.SetTimeout(tutorialTargetMS, t.resolveTarget)
// 2. the card. A jump that lands mid-transition updates the index (and the target)
// but does not restart the choreography — the in-flight swap reads the LIVE
// index, so it lands on the newest step anyway.
if t.transitioning {
return
}
t.transitioning = true
t.firstAppearance = false
wasmruntime.SetStyle(t.popoverRef, "transition", tutorialPopoverSlide)
t.showArrow.Set(false)
wasmruntime.SetStyle(t.contentRef, "opacity", "0")
t.stepTimer = wasmruntime.SetTimeout(tutorialHalfMS, func() {
t.stepTimer = 0
if !t.active.Get() {
t.transitioning = false
return
}
t.displayed.Set(t.index.Get()) // swap at the midpoint of the fade
wasmruntime.AfterRender(func() {
// The new content is in the DOM: measure it and let the card slide.
t.updatePosition()
t.stepTimer = wasmruntime.SetTimeout(tutorialSettleMS, func() {
wasmruntime.SetStyle(t.contentRef, "opacity", "1")
t.stepTimer = wasmruntime.SetTimeout(tutorialHalfMS, func() {
t.stepTimer = 0
t.transitioning = false
t.showArrow.Set(true)
})
})
})
})
}
// Dispose ends the tour and removes every listener and timer.
func (t *Tutorial) Dispose() {
t.teardown()
if t.active.Get() {
t.active.Set(false)
}
}
func (t *Tutorial) teardown() {
for _, un := range t.unsubs {
un()
}
t.unsubs = nil
wasmruntime.ClearTimeout(t.targetTimer)
wasmruntime.ClearTimeout(t.stepTimer)
wasmruntime.ClearTimeout(t.arrowTimer)
t.targetTimer, t.stepTimer, t.arrowTimer = 0, 0, 0
}
// mounted runs once the overlay and card are in the DOM.
func (t *Tutorial) mounted() {
// The spotlight's corners should match the app's theme, so read the radius token
// rather than guessing. CSSVarPx resolves rem against the root font size.
t.radius = wasmruntime.CSSVarPx("--radius-default")
if t.radius <= 0 {
t.radius = tutorialFallbackRadius
}
t.unsubs = append(t.unsubs,
wasmruntime.OnDocument(vdom.EVENT_KEYDOWN, false, t.onKeydown),
// capture=true: `scroll` does not bubble, so this is the only way to hear a
// scroll inside a nested container the target happens to live in.
wasmruntime.OnWindow(vdom.EVENT_SCROLL, true, func(vdom.Event) { t.onViewportChange() }),
wasmruntime.OnWindow(vdom.EVENT_RESIZE, false, func(vdom.Event) { t.onViewportChange() }),
)
t.writeOverlay() // no rect yet: the flat dim, fading in
t.targetTimer = wasmruntime.SetTimeout(tutorialTargetMS, t.resolveTarget)
// Position on the next frame, reveal on the one after: the initial (faded, scaled)
// style must be COMMITTED before the target lands, or there is nothing for the
// browser to interpolate from and the card just appears.
wasmruntime.RAF(func() {
t.updatePosition()
t.positioned = true
wasmruntime.RAF(func() {
wasmruntime.SetStyle(t.popoverRef, "opacity", "1")
wasmruntime.SetStyle(t.popoverRef, "transform", "scale(1)")
t.arrowTimer = wasmruntime.SetTimeout(tutorialAnimMS, func() {
t.arrowTimer = 0
if t.active.Get() {
t.showArrow.Set(true)
}
})
})
})
}
// onKeydown: Escape ends the tour, ArrowRight/Enter advance, ArrowLeft goes back.
func (t *Tutorial) onKeydown(ev vdom.Event) {
switch ev.Key() {
case vdom.KEY_ESCAPE:
t.End()
case vdom.KEY_ARROW_RIGHT, vdom.KEY_ENTER:
t.Next()
case vdom.KEY_ARROW_LEFT:
t.Previous()
}
}
// ---- the target ----
// targetRef finds the current step's target in the document. The selector is
// re-queried on every measurement rather than cached, so a target that is re-rendered
// (a virtualised row, a re-keyed panel) does not leave the tour pointing at a detached
// node.
func (t *Tutorial) targetRef() *vdom.Ref {
s := t.step(t.index.Get())
if s == nil || s.Target == "" {
return nil
}
r := wasmruntime.QuerySelector(s.Target)
if !r.Mounted() {
return nil
}
return r
}
// resolveTarget runs once per step, on a short delay: measure the target and bring it
// into view. The smooth scroll is deliberately NOT repeated on every scroll frame (the
// TSX did, which fought the user for the scroll position); it belongs to entering a
// step.
func (t *Tutorial) resolveTarget() {
t.targetTimer = 0
if !t.active.Get() {
return
}
r := t.targetRef()
if r == nil {
t.rect = wasmruntime.Rect{}
t.applyOverlay()
return
}
t.rect = wasmruntime.Measure(r)
wasmruntime.ScrollIntoView(r, true, wasmruntime.ScrollBlockCenter)
t.applyOverlay()
if !t.transitioning && t.positioned {
t.updatePosition()
}
}
// onViewportChange re-measures on scroll and resize. Both the spotlight and the card
// follow the target — imperatively, so a scroll frame costs two measurements and a few
// style writes rather than a re-render of the app.
func (t *Tutorial) onViewportChange() {
if !t.active.Get() {
return
}
if r := t.targetRef(); r != nil {
t.rect = wasmruntime.Measure(r)
} else {
t.rect = wasmruntime.Rect{}
}
t.applyOverlay()
if !t.transitioning && t.positioned {
t.updatePosition()
}
}
// hasSpotlight reports whether there is a measured target to cut a hole around. A step
// with no target — or one whose selector matched nothing, or nothing laid out — falls
// back to the flat dim. On the server this is always false, which is why SSR ships a
// tour that is simply not running rather than a black screen.
func (t *Tutorial) hasSpotlight() bool {
s := t.step(t.index.Get())
return s != nil && s.Target != "" && !t.rect.Empty()
}
// ---- the overlay ----
// applyOverlay reconciles the overlay with the current measurement. Flipping between
// the cutout and the flat dim swaps the rendered node, so the styles have to be
// (re)written once that render has committed; when nothing flips, the write is direct.
func (t *Tutorial) applyOverlay() {
has := t.hasSpotlight()
if t.spotlight.Get() != has {
t.spotlight.Set(has)
t.revealed = false // a new node: it must fade its dim in again
wasmruntime.AfterRender(t.writeOverlay)
return
}
t.writeOverlay()
}
// writeOverlay writes the overlay's geometry, then fades its dim in the first time it
// runs on a given node. Every property of the mode NOT in use is removed, so a node the
// reconciler reused across a flip cannot keep a stale inline `left` and drag the flat
// backdrop off screen.
func (t *Tutorial) writeOverlay() {
if !t.active.Get() || !t.overlayRef.Mounted() {
return
}
if t.spotlight.Get() {
pad := t.opts.SpotlightPadding
wasmruntime.RemoveStyle(t.overlayRef, "opacity")
wasmruntime.SetStyle(t.overlayRef, "left", px(t.rect.Left()-pad))
wasmruntime.SetStyle(t.overlayRef, "top", px(t.rect.Top()-pad))
wasmruntime.SetStyle(t.overlayRef, "width", px(t.rect.Width+pad*2))
wasmruntime.SetStyle(t.overlayRef, "height", px(t.rect.Height+pad*2))
wasmruntime.SetStyle(t.overlayRef, "border-radius", px(t.radius))
} else {
for _, p := range []string{"left", "top", "width", "height", "border-radius", "box-shadow"} {
wasmruntime.RemoveStyle(t.overlayRef, p)
}
}
if t.revealed {
t.revealOverlay() // idempotent; keeps the dim applied on a freshly swapped node
return
}
t.revealed = true
// Double rAF, for the same reason the modal needs one: the transparent initial
// state has to be committed a frame before the dim lands, or it does not fade.
wasmruntime.RAF(func() { wasmruntime.RAF(t.revealOverlay) })
}
func (t *Tutorial) revealOverlay() {
if !t.active.Get() || !t.overlayRef.Mounted() {
return
}
if t.spotlight.Get() {
wasmruntime.SetStyle(t.overlayRef, "box-shadow", tutorialSpotlightShadow)
return
}
wasmruntime.SetStyle(t.overlayRef, "opacity", "1")
}
// ---- the card ----
// updatePosition measures the card, works out where it goes, and writes it. This is the
// only place the card's coordinates are decided, and it never goes through a signal: it
// runs on every scroll frame.
//
// The placement math is ComputePosition (position.go) — the same engine the floating
// layer uses. The TSX had its own fourth copy of it, which clamped on BOTH axes and so
// could push the card on top of the very thing it was pointing at; this one shifts on
// the cross axis only and flips on the main one.
func (t *Tutorial) updatePosition() {
if !t.popoverRef.Mounted() {
return
}
card := wasmruntime.Measure(t.popoverRef)
if card.Empty() {
return // not laid out yet; stay hidden rather than paint at 0,0
}
vp := wasmruntime.Viewport()
step := t.step(t.index.Get())
placement := PlacementBottom
var top, left float64
if step != nil && step.Target != "" && !t.rect.Empty() {
offset := step.Offset
if offset == 0 {
offset = tutorialDefaultOff
}
pos := ComputePosition(t.rect, card, vp, PositionOptions{
Placement: pick(step.Placement, PlacementBottom),
Offset: offset,
Flip: true,
Shift: true,
Padding: tutorialViewportPad,
})
top, left, placement = pos.Top, pos.Left, pos.Placement
} else {
// No target (or nothing measurable): centre the card in the viewport.
top = (vp.Height - card.Height) / 2
left = (vp.Width - card.Width) / 2
}
wasmruntime.SetStyle(t.popoverRef, "top", px(top))
wasmruntime.SetStyle(t.popoverRef, "left", px(left))
wasmruntime.SetStyle(t.popoverRef, "visibility", "visible")
// The arrow's side is a rendered decision, so a flip has to go through a render.
// Guarded on change, so this converges after one extra render instead of looping.
if t.placement.Get() != placement {
t.placement.Set(placement)
}
}
// ---- rendering ----
// Render draws the tour — the overlay and the card — portaled to document.body, so no
// ancestor's `overflow: hidden` can clip the spotlight and no ancestor `transform` can
// re-root its `position: fixed`. When the tour is not running it renders an EMPTY
// portal rather than nothing, so its slot in the parent's child list (which the
// reconciler diffs by index) never disappears.
func (t *Tutorial) Render() *vdom.VNode {
if !t.active.Get() || len(t.opts.Steps) == 0 {
return vdom.Portal()
}
return vdom.Portal(t.overlay(), t.card())
}
// overlay is either the spotlight cutout (a hole in a 9999px shadow) or, with no target
// to cut around, the flat dim. Both start transparent and are faded in imperatively.
func (t *Tutorial) overlay() *vdom.VNode {
if !t.spotlight.Get() {
return vdom.Div(vdom.WithRef(t.overlayRef),
vdom.Attr("class", tutorialBackdropClass),
vdom.Attr("style", tutorialBackdropStyle),
vdom.On(vdom.EVENT_CLICK, t.End),
)
}
return vdom.Div(vdom.WithRef(t.overlayRef),
vdom.Attr("class", tutorialSpotlightClass),
vdom.Attr("style", tutorialSpotlightStyle),
// The hole itself is pointer-events:none (you can still use what it spotlights);
// this sits behind the whole page and ends the tour when the dim is clicked.
vdom.Div(vdom.Attr("class", tutorialCatcherClass), vdom.On(vdom.EVENT_CLICK, t.End)),
)
}
// card renders the popover: the two arrow triangles, then the content (header, body,
// controls, dots) in its own cross-faded wrapper.
//
// The arrows are always rendered, and merely hidden with display:none when they are not
// wanted. That is deliberate: the reconciler diffs children BY INDEX, so adding and
// removing them would shift the content div's index and force it to be rebuilt —
// discarding the imperative opacity mid-cross-fade.
func (t *Tutorial) card() *vdom.VNode {
idx := clampIndex(t.displayed.Get(), len(t.opts.Steps))
step := t.step(idx)
total := len(t.opts.Steps)
base, _ := splitPlacement(t.placement.Get())
arrowVisible := t.showArrow.Get() && step != nil && step.Target != ""
hidden := ""
if !arrowVisible {
hidden = "display:none;"
}
outer := vdom.Div(vdom.Attr("class", tutorialArrowClass),
vdom.Attr("style", tutorialArrowOuter[base]+tutorialArrowTransition+hidden),
)
inner := vdom.Div(vdom.Attr("class", tutorialArrowClass),
vdom.Attr("style", tutorialArrowInner[base]+tutorialArrowTransition+hidden),
)
return vdom.Div(vdom.WithRef(t.popoverRef),
vdom.Attr("class", cx(tutorialPopoverClass, t.opts.Class)),
vdom.Attr("style", tutorialPopoverStyle),
outer,
inner,
t.content(step, idx, total),
)
}
func (t *Tutorial) content(step *TutorialStep, idx, total int) *vdom.VNode {
// Header: optional title + "X of N", and a close button.
headerLeft := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
if step.Title != "" {
headerLeft = append(headerLeft, vdom.El("span",
vdom.Attr("class", "font-medium text-neutral-900"),
if step != nil && step.Title != "" {
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "font-medium text-neutral-900"),
vdom.Text(step.Title)))
}
headerLeft = append(headerLeft, vdom.El("span",
vdom.Attr("class", "text-xs text-neutral-500"),
headerLeft = append(headerLeft, vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"),
vdom.Text(strconv.Itoa(idx+1)+" of "+strconv.Itoa(total))))
closeMods := []vdom.Mod{
vdom.Attr("type", "button"),
header := vdom.Div(vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
vdom.Div(headerLeft...),
vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", "cursor-pointer text-neutral-400 bg-transparent border-0 p-0 leading-none transition-colors hover:text-neutral-600"),
vdom.On(vdom.EVENT_CLICK, t.End),
Icon("xmark", 18, ""),
}
if p.OnClose != nil {
closeMods = append(closeMods, vdom.On(vdom.EVENT_CLICK, p.OnClose))
}
header := vdom.El("div", vdom.Attr("class", "flex items-center justify-between p-4 pb-2"),
vdom.El("div", headerLeft...),
vdom.El("button", closeMods...),
),
)
// Body content.
// Body.
bodyMods := []vdom.Mod{vdom.Attr("class", "px-4 pb-4 text-sm text-neutral-700")}
if step.Content != nil {
bodyMods = append(bodyMods, step.Content)
if step != nil && step.Content != nil {
if c := step.Content(); c != nil {
bodyMods = append(bodyMods, c)
}
body := vdom.El("div", bodyMods...)
// Controls: Previous on the left (hidden on the first step); Next/Finish right.
isFirst := idx == 0
isLast := idx == total-1
}
body := vdom.Div(bodyMods...)
// Controls: Previous on the left (absent on the first step); Next/Finish on the right.
leftMods := []vdom.Mod{}
if !isFirst {
leftMods = append(leftMods, Button(ButtonProps{Color: ButtonWhite, Small: true, Text: "Previous", OnClick: p.OnPrev}))
if idx > 0 {
leftMods = append(leftMods, Button(ButtonProps{Color: ButtonWhite, Small: true, Text: "Previous", OnClick: t.Previous}))
}
var advance *vdom.VNode
if isLast {
advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Finish", OnClick: p.OnClose})
} else {
advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Next", OnClick: p.OnNext})
advance := Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Next", OnClick: t.Next})
if idx == total-1 {
advance = Button(ButtonProps{Color: ButtonBlue, Small: true, Text: "Finish", OnClick: t.End})
}
controls := vdom.El("div", vdom.Attr("class", "flex items-center justify-between px-4 pb-4 gap-2"),
vdom.El("div", leftMods...),
vdom.El("div", vdom.Attr("class", "flex gap-2"), advance),
controls := vdom.Div(vdom.Attr("class", "flex items-center justify-between px-4 pb-4 gap-2"),
vdom.Div(leftMods...),
vdom.Div(vdom.Attr("class", "flex gap-2"), advance),
)
// NOTE: original card class kept verbatim; the centering utilities
// (left/top/-translate) are the static stand-in for computed positioning.
popMods := []vdom.Mod{
vdom.Attr("class", cx("fixed left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 z-200 bg-white rounded-default shadow-lg border border-neutral-200 max-w-sm", p.Class)),
contentMods := []vdom.Mod{
vdom.WithRef(t.contentRef),
vdom.Attr("style", tutorialContentStyle),
header, body, controls,
}
// Step dots (only when there is more than one step).
// Step dots.
if total > 1 {
dotMods := []vdom.Mod{vdom.Attr("class", "flex justify-center gap-1.5 pb-3")}
for i := 0; i < total; i++ {
@@ -143,44 +753,18 @@ func tutorialPopover(p TutorialProps, idx int) *vdom.VNode {
if i == idx {
state = "bg-sky-600 scale-110"
}
dotMods = append(dotMods, vdom.El("div",
vdom.Attr("class", cx("w-2 h-2 rounded-full transition-all duration-300 ease-in-out", state))))
dotMods = append(dotMods, vdom.Div(vdom.Attr("class", cx("w-2 h-2 rounded-full transition-all duration-300 ease-in-out", state))))
}
popMods = append(popMods, vdom.El("div", dotMods...))
contentMods = append(contentMods, vdom.Div(dotMods...))
}
return vdom.El("div", popMods...)
return vdom.Div(contentMods...)
}
// TutorialProvider renders children, and — when the tour is Active — the dimmed
// backdrop plus the popover card for the current step over the top. The wrapper
// uses display:contents so it does not introduce its own layout box (the Solid
// source returned a fragment). Returns just the children when inactive or empty.
//
// NOTE: unlike the Solid provider, children cannot read tour state via context;
// the caller threads Active/CurrentStep/callbacks in through TutorialProps.
func TutorialProvider(p TutorialProps, children ...*vdom.VNode) *vdom.VNode {
mods := kids([]vdom.Mod{vdom.Attr("class", "contents")}, children)
if p.Active && len(p.Steps) > 0 {
idx := p.CurrentStep
if idx < 0 {
idx = 0
}
if idx > len(p.Steps)-1 {
idx = len(p.Steps) - 1
}
mods = append(mods, tutorialOverlay(p), tutorialPopover(p, idx))
}
return vdom.El("div", mods...)
}
// StartTutorialButton is a blue button that kicks off the tour. onStart is the
// caller's start handler (which decides the starting step index). With no
// children it renders the default "Start Tutorial" label.
func StartTutorialButton(onStart func(), class string, children ...*vdom.VNode) *vdom.VNode {
bp := ButtonProps{Color: ButtonBlue, Class: class, OnClick: onStart}
// StartButton is the blue button that kicks the tour off at stepIndex. With no children
// it renders the default "Start Tutorial" label.
func (t *Tutorial) StartButton(stepIndex int, class string, children ...*vdom.VNode) *vdom.VNode {
bp := ButtonProps{Color: ButtonBlue, Class: class, OnClick: func() { t.Start(stepIndex) }}
if len(children) == 0 {
bp.Text = "Start Tutorial"
}

View File

@@ -15,11 +15,32 @@
// - Tailwind classes are copied verbatim so kjol/cmd/twcss (scanning these .go
// files) emits the matching CSS. Custom tokens (rounded-default, bg-primary,
// text-text-heading, …) come from the app's @theme block.
// - Browser-only behavior (floating-ui positioning, portals, focus traps,
// element measurement) has no equivalent in the neutral runtime; those
// components port their structure + Tailwind + signal/event wiring, and
// approximate positioning with CSS where possible. Such gaps are marked
// with a NOTE in the component's file.
//
// # Browser-backed components
//
// Anything that has to measure the page — a popover that must not fall off the
// screen, a column you can drag wider, a tour that spotlights an element — reaches
// the browser through the host API in kjol/wasmruntime (Measure, Viewport, SetStyle,
// RAF, AfterRender, OnDocument/OnWindow, storage, …). That API is dual-build: real
// in the browser, no-op stubs natively. So these components stay neutral and still
// server-render: on the server every measurement is the zero Rect, no listener is
// installed, and the component renders its unmeasured state (a panel that is closed,
// a table in its declared column order). See kjol/wasmruntime/host.go.
//
// Such components are CONTROLLERS, not plain functions: they own refs, timers and
// open state, so they must be created ONCE — alongside your signals, never inside a
// render closure, which would rebuild them every frame:
//
// menu := webui.NewMenu(webui.MenuOptions{})
// table := webui.NewAutoTableState(cols, webui.AutoTableStateOptions{})
//
// return func() *vdom.VNode {
// return Div(menu.Trigger(…), menu.Content(…), table.Render(…))
// }
//
// Floating panels (Tooltip, Popover, Menu, Submenu, DatePicker, Modal, Tutorial) are
// all built on the Floating controller in floating.go, which portals them to
// document.body and positions them with the engine in position.go.
package webui
import (