// Port of web/uikit/Popovers.tsx, rebuilt on the Floating controller (floating.go). // // The first Go port dropped everything that made a popover a popover: measured // placement (it used static absolute utilities off a `relative inline-block` // wrapper), the portal, the single-open manager, outside-click / Escape dismissal, // and — for HoverPopover — the hover bridge, which it faked with Tailwind's // `group-hover`. That fake only worked because the panel was a DOM descendant of the // hovered wrapper. The panel is now PORTALED to document.body, so it is not a // descendant of anything the cursor is over and CSS :hover cannot reach it: the // reveal MUST be the timer-based bridge in Floating (OpenOnHover + HoverDelay / // HoverCloseDelay), which holds the panel open while the cursor crosses the gap and // while it rests on the panel itself. That is what NewHoverPopover wires up. // // Shape of the port: the TSX's element was only a carrier for a Solid // context, which Go has no equivalent of, so it becomes a CONTROLLER instead — the // state (open, refs, timers, resolved placement) has to outlive a render, so it // cannot be created inside one. There is no wrapper element left: the trigger sits // where you put it and the panel is portaled to the body, so nothing needs to // contain them both. // // pop := webui.NewPopover(webui.PopoverProps{Placement: webui.PlacementBottomEnd}) // return func() *vdom.VNode { // return vdom.Div( // pop.Trigger(webui.PopoverTriggerProps{Class: "btn"}, vdom.Text("Filters")), // pop.Content(webui.PopoverContentProps{Class: "p-3 w-64"}, body()...), // ) // } package webui import "kjol/vdom" const popoverCls = "bg-white rounded-default shadow-lg border border-neutral-200" // TSX defaults: offset ?? 8, hoverDelay ?? 0, hoverCloseDelay ?? 150. const popoverOffset = 8 // PopoverProps configures NewPopover. type PopoverProps struct { // Placement defaults to "bottom-start"; the resolved placement may differ after // a flip (read it with Placement()). Placement string // Offset is the gap between trigger and panel in px; 0 means the TSX default, 8. Offset float64 // Standalone opts out of the single-open manager: this popover neither closes, // nor is closed by, other floatings. Set it for a popover nested inside another // floating (a popover opened from a menu item), which would otherwise close its // own parent the moment it opened. Standalone bool // OnOpenChange is called whenever the panel opens or closes — for callers that // mirror the state (the popover owns it either way; there is no `Open` input, // because a controller that both owns state and takes it is a race). OnOpenChange func(bool) } // HoverPopoverProps configures NewHoverPopover. It is PopoverProps plus the two // hover timings (the TSX's HoverPopoverProps extends PopoverProps the same way). type HoverPopoverProps struct { Placement string Offset float64 Standalone bool OnOpenChange func(bool) // HoverDelay is how long the cursor must rest on the trigger before the panel // opens (default 0 — the TSX default). HoverCloseDelay is the grace period after // the cursor leaves the trigger OR the panel: the bridge across the gap between // them (default 150ms). HoverDelay int HoverCloseDelay int } // Popover is a live popover — the controller that NewPopover and NewHoverPopover // both return. Build it once, alongside your signals, NOT inside a render function // (a Floating rebuilt every frame would lose its refs, its timers and its open // state, i.e. it would never open). type Popover struct{ f *Floating } // NewPopover creates a click-to-open popover. func NewPopover(p PopoverProps) *Popover { return &Popover{f: NewFloating(FloatingOptions{ Placement: pick(p.Placement, PlacementBottomStart), Offset: pickOffset(p.Offset, popoverOffset), Standalone: p.Standalone, OnOpenChange: p.OnOpenChange, })} } // NewHoverPopover creates a hover-to-open popover. // // The hover bridge lives in Floating and is wired on BOTH the trigger and the panel // (Trigger/Panel install it when OpenOnHover is set), so the panel survives the // cursor's trip across the gap and stays up while the cursor is on it — the thing // the old CSS group-hover version could not do once the panel was portaled out of // the wrapper. func NewHoverPopover(p HoverPopoverProps) *Popover { return &Popover{f: NewFloating(FloatingOptions{ Placement: pick(p.Placement, PlacementBottomStart), Offset: pickOffset(p.Offset, popoverOffset), Standalone: p.Standalone, OnOpenChange: p.OnOpenChange, OpenOnHover: true, HoverDelay: p.HoverDelay, HoverCloseDelay: p.HoverCloseDelay, // 0 → NewFloating's 150ms })} } // Floating exposes the underlying controller (Reposition, Dispose, ...). func (p *Popover) Floating() *Floating { return p.f } // IsOpen reports the current state; Placement is the placement AFTER any flip. func (p *Popover) IsOpen() bool { return p.f.IsOpen() } func (p *Popover) Placement() string { return p.f.Placement() } // Show, Hide and Toggle drive the panel from outside (a keyboard shortcut, a // "close" button inside the panel, a route change). func (p *Popover) Show() { p.f.Show() } func (p *Popover) Hide() { p.f.Hide() } func (p *Popover) Toggle() { p.f.Toggle() } // Dispose closes the panel and drops every listener and timer it owns. func (p *Popover) Dispose() { p.f.Dispose() } // PopoverTriggerProps configures Trigger. The Open/OnToggle fields the old port had // are gone: the Popover owns its open state now, and the trigger it renders drives // it (click for NewPopover, hover/focus for NewHoverPopover). type PopoverTriggerProps struct { Class string Title string // OnClick runs in addition to the open/close (for a hover popover, whose trigger // is not a toggle, it is the only thing a click does). OnClick func() } // Trigger renders the