536 lines
16 KiB
Go
536 lines
16 KiB
Go
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"
|
|
}
|