407 lines
19 KiB
Go
407 lines
19 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 /wasm/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 docPage("Components", "Overlays",
|
|
"Tooltips, popovers, menus, modals and toasts — every one of them measured against the real "+
|
|
"viewport. A floating panel is portaled to document.body, positioned from its trigger's "+
|
|
"bounding box, and flipped or shifted when it would otherwise run off the screen.",
|
|
|
|
docSection("engine", "How a panel is placed",
|
|
prose("Positioning is a pure function: given the trigger's rectangle, the panel's size and the "+
|
|
"viewport, it returns coordinates. It is unit-tested natively, with no browser in sight, "+
|
|
"because none of it is about the browser — the browser only supplies the three rectangles."),
|
|
prose("The result is written to the element with SetStyle, NOT through a signal. A signal write "+
|
|
"re-renders the whole tree, and this runs on every scroll and resize frame; going through "+
|
|
"the vdom would rebuild the page sixty times a second to move one panel four pixels."),
|
|
code("webui/floating.go", floatingSnippet),
|
|
note("Controllers are built once",
|
|
"A floating component owns refs, timers and its open state. Build it alongside your signals, "+
|
|
"never inside the render closure — one built per frame can never stay open, because the "+
|
|
"thing holding \"open\" is thrown away and replaced before you can see it."),
|
|
row("mt-4 flex gap-2", tour.StartButton(0, "", Text("Take the tour"))),
|
|
),
|
|
|
|
// ---- tooltips ----
|
|
docSection("demo-tooltips", "Tooltips",
|
|
prose("Hover, or focus — a tooltip that only answers to a mouse is a tooltip a keyboard user "+
|
|
"cannot read. Narrow the window and hover the Right one: it flips to the left, and its "+
|
|
"arrow follows it. Near an edge the panel shifts back on screen and the arrow slides to "+
|
|
"keep pointing at the trigger; in the original kit the arrow detached and pointed at "+
|
|
"nothing."),
|
|
demo("Placement, delay, and focus triggers",
|
|
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"})),
|
|
),
|
|
),
|
|
),
|
|
|
|
// ---- popovers ----
|
|
docSection("demo-popovers", "Popovers",
|
|
prose("A popover closes on an outside click or on Escape — and only the TOPMOST one closes per "+
|
|
"press, so a dropdown inside a popover does not take the popover down with it. The hover "+
|
|
"variant keeps a bridge across the gap between trigger and panel, so the cursor can "+
|
|
"actually reach the thing it opened."),
|
|
demo("Click, alignment, and hover-with-a-bridge",
|
|
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-ink-soft"),
|
|
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-ink-soft"), 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-ink-soft"),
|
|
Text("Move the cursor across the gap and onto this panel — it stays open. "+
|
|
"Select this text to prove it.")),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// ---- menus ----
|
|
docSection("demo-menus", "Menus & submenus",
|
|
prose("Opening one menu closes the other: a single-open manager keeps the page from filling up "+
|
|
"with panels nobody asked for. Submenus are exempt from it — they are Standalone — or a "+
|
|
"submenu would close the very menu it belongs to as it opened."),
|
|
prose("A submenu is portaled too, which is not a detail: the parent menu scrolls its own "+
|
|
"contents, and a submenu rendered inside it was clipped by that overflow the moment it "+
|
|
"was taller than its parent."),
|
|
demo("Items, icons, a submenu, and KeepOpen",
|
|
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")),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// ---- date pickers ----
|
|
docSection("demo-dates", "Date picker",
|
|
prose("The field is typeable, not merely clickable. It parses loosely — 7/4/26, Jul 4 2026 and "+
|
|
"2026-07-04 all work — and commits what it understood on blur, so the calendar is an "+
|
|
"affordance rather than the only way in."),
|
|
demo("Picked: \""+picked.Get()+"\"",
|
|
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(),
|
|
),
|
|
),
|
|
),
|
|
),
|
|
|
|
// ---- modals ----
|
|
docSection("demo-modals", "Modals",
|
|
prose("Portaled to document.body, so no ancestor's overflow:hidden or transform can clip them. "+
|
|
"Open the modal, then the nested one inside it, and press Escape twice: modals unwind one "+
|
|
"layer per press rather than all at once."),
|
|
prose("The last button opens a modal that no component in the tree owns — webui.OpenModal hands "+
|
|
"content to a shared host rendered once in the layout. That is what code far from the view "+
|
|
"needs: a confirmation raised from inside a save handler, say."),
|
|
demo("Deleted: "+strconv.FormatBool(deleted.Get()),
|
|
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-ink-soft"),
|
|
Text("This content was not rendered by any component — it was handed to "+
|
|
"ModalHost (see AppLayout) by webui.OpenModal.")),
|
|
)
|
|
}, ui.ModalOptions{Size: ui.ModalSmall})
|
|
}}),
|
|
),
|
|
),
|
|
|
|
// The modals themselves. They portal to document.body, so where they sit in
|
|
// the tree makes no difference to where they appear.
|
|
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-ink-soft"),
|
|
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-ink-soft"), 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-ink-soft"),
|
|
Text("All set for "+wizardName.Get()+". Finish to close."))
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
),
|
|
|
|
// ---- toasts ----
|
|
docSection("demo-toasts", "Toasts",
|
|
prose("They dismiss themselves after five seconds. Watch the bar count down: it is one CSS "+
|
|
"transition, written straight at the element — not a re-render per frame, which is what a "+
|
|
"progress bar driven through a signal would cost you."),
|
|
prose("A sticky toast (Duration: ToastSticky) waits for the user instead. The menu items above "+
|
|
"raise toasts too, which is how you can see that an item really does close its own menu."),
|
|
demo("Push, dismiss, and a sticky one",
|
|
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}),
|
|
),
|
|
),
|
|
),
|
|
|
|
docSection("overlay-api", "Reference",
|
|
apiTable(
|
|
apiRow{"NewFloating", "The positioning engine behind every panel: placement, offset, flip, shift, arrow."},
|
|
apiRow{"NewTooltip / NewPopover / NewMenu", "Controllers. Build once, outside the render."},
|
|
apiRow{"Standalone", "Exempts a panel from the single-open manager. A submenu needs it, or it closes its own parent."},
|
|
apiRow{"vdom.Portal", "Mounts children at document.body — the escape hatch from an ancestor's overflow:hidden."},
|
|
apiRow{"webui.OpenModal / ModalHost", "Open a modal from code that owns no component. Render the host once, in your layout."},
|
|
),
|
|
),
|
|
|
|
// 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(),
|
|
)
|
|
}
|
|
}
|
|
|
|
const floatingSnippet = `// Built ONCE — it owns refs, timers, and whether it is open.
|
|
pop := ui.NewPopover(ui.PopoverOptions{
|
|
Placement: ui.PlacementBottomStart,
|
|
Offset: 8,
|
|
})
|
|
|
|
// ...and in the render:
|
|
pop.Trigger(ui.PopoverTriggerProps{},
|
|
ui.Button(ui.ButtonProps{Text: "Click me"}),
|
|
)
|
|
pop.Content(ui.PopoverContentProps{Class: "w-64"},
|
|
P(Text("Outside click and Escape close me.")),
|
|
)
|
|
|
|
// The panel is portaled to document.body and positioned imperatively:
|
|
// render invisible -> AfterRender -> measure -> ComputePosition -> SetStyle -> reveal
|
|
// Never through a signal: this runs on every scroll frame.`
|