Files
kjol/go/cmd/examples/go-wasm-web/app/overlays.go

351 lines
15 KiB
Go

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(),
)
}
}