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 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.`