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