Add fonts, autotable, autotable examples
This commit is contained in:
@@ -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.MenuDivider(""),
|
||||
ui.MenuItem(ui.MenuItemProps{OnClick: func() { menuOpen.Set(false) }}, Text("Sign out")),
|
||||
),
|
||||
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(""),
|
||||
menu.Item(ui.MenuItemProps{}, Text("Sign out")),
|
||||
),
|
||||
// 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"})),
|
||||
|
||||
// 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"})),
|
||||
),
|
||||
ui.Modal(ui.ModalProps{
|
||||
IsOpen: modalOpen.Get(),
|
||||
OnClose: func() { modalOpen.Set(false) },
|
||||
Size: ui.ModalMedium,
|
||||
Header: H3(Attr("class", "text-lg font-semibold text-text-heading"), Text("Example modal")),
|
||||
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.")),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
350
go/cmd/examples/go-wasm-web/app/overlays.go
Normal file
350
go/cmd/examples/go-wasm-web/app/overlays.go
Normal 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(),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -6,12 +6,14 @@ import "kjol/vdom"
|
||||
// Routes maps each //gowasm:page path to its instantiated render function.
|
||||
func Routes(d Deps) map[string]func() *vdom.VNode {
|
||||
return map[string]func() *vdom.VNode{
|
||||
"/": HomePage(d),
|
||||
"/about": AboutPage(d),
|
||||
"/chart": ChartPage(d),
|
||||
"/data": DataPage(d),
|
||||
"/kit": KitPage(d),
|
||||
"/server": ServerPage(d),
|
||||
"/": HomePage(d),
|
||||
"/about": AboutPage(d),
|
||||
"/chart": ChartPage(d),
|
||||
"/data": DataPage(d),
|
||||
"/kit": KitPage(d),
|
||||
"/overlays": OverlaysPage(d),
|
||||
"/server": ServerPage(d),
|
||||
"/table": TablePage(d),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,16 +23,19 @@ 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.
|
||||
var RouteLayout = map[string]string{
|
||||
"/": "public",
|
||||
"/about": "public",
|
||||
"/chart": "app",
|
||||
"/data": "app",
|
||||
"/kit": "app",
|
||||
"/server": "app",
|
||||
"/": "public",
|
||||
"/about": "public",
|
||||
"/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.
|
||||
|
||||
171
go/cmd/examples/go-wasm-web/app/ssr_test.go
Normal file
171
go/cmd/examples/go-wasm-web/app/ssr_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
290
go/cmd/examples/go-wasm-web/app/table.go
Normal file
290
go/cmd/examples/go-wasm-web/app/table.go
Normal 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.")),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user