Files
kjol/go/webui/floating.go

547 lines
17 KiB
Go

package webui
import (
"strconv"
"kjol/wasmruntime"
"kjol/wasmruntime/vdom"
)
// 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()
// NoToggle anchors the panel to this element without making it a switch.
//
// A text field is not a switch: the panel opens because you typed, and closes
// because you chose something. Toggling on click means clicking back into the field
// to fix a typo dismisses the results you were reading.
NoToggle bool
}
// 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 if p.NoToggle {
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"
}