// Port of web/kit/Popovers.tsx. // // NOTE: the TSX builds these on Floating.tsx — a floating-ui-style layer with // getBoundingClientRect measurement, requestAnimationFrame reposition, a Portal // to document.body, a global single-open manager, outside-click/Escape handling, // and hover open/close timers, all threaded through a FloatingContext. None of // that has an equivalent in the neutral runtime (no document, portals, refs, // element measurement, or timers), so it is dropped. This port keeps the // component API + Tailwind + event wiring, drives click open/close with a // caller-supplied `open` bool + toggle callback, does hover reveal purely in CSS // (group-hover), and approximates placement with static absolute utility classes // instead of computed coordinates (so flip/shift and a numeric offset are gone — // the gap is a fixed ~8px via the m*-2 classes, matching the TSX default offset). package webui import "kjol/vdom" const popoverCls = "bg-white rounded-default shadow-lg border border-neutral-200" // popoverPlacementCls maps a floating placement to static absolute-position // utility classes relative to the Popover's `relative` wrapper. func popoverPlacementCls(placement string) string { switch placement { case "top", "top-start": return "bottom-full left-0 mb-2" case "top-end": return "bottom-full right-0 mb-2" case "bottom-end": return "top-full right-0 mt-2" case "left", "left-start": return "right-full top-0 mr-2" case "left-end": return "right-full bottom-0 mr-2" case "right", "right-start": return "left-full top-0 ml-2" case "right-end": return "left-full bottom-0 ml-2" default: // "bottom" / "bottom-start" and unknown return "top-full left-0 mt-2" } } // PopoverProps configures Popover. Placement defaults to "bottom-start". type PopoverProps struct { Placement string Class string } // Popover is the floating wrapper: a relative container that anchors an // absolutely-positioned PopoverContent to a PopoverTrigger. Compose a // PopoverTrigger and a PopoverContent as its children. func Popover(p PopoverProps, children ...*vdom.VNode) *vdom.VNode { return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("relative inline-block", p.Class))}, children)...) } // PopoverTriggerProps configures PopoverTrigger. Open drives aria-expanded and // OnToggle fires on click (the TSX toggles open on click). type PopoverTriggerProps struct { Open bool OnToggle func() Class string Title string } // PopoverTrigger is the click target that opens/closes the popover. func PopoverTrigger(p PopoverTriggerProps, children ...*vdom.VNode) *vdom.VNode { expanded := "false" if p.Open { expanded = "true" } mods := []vdom.Mod{ vdom.Attr("type", "button"), vdom.Attr("class", p.Class), vdom.Attr("aria-expanded", expanded), vdom.Attr("aria-haspopup", "menu"), } if p.Title != "" { mods = append(mods, vdom.Attr("title", p.Title)) } if p.OnToggle != nil { mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnToggle)) } return vdom.El("button", kids(mods, children)...) } // PopoverContentProps configures PopoverContent. Placement defaults to // "bottom-start"; Open toggles visibility (nil when closed). type PopoverContentProps struct { Open bool Placement string Class string } // PopoverContent is the floating panel. It renders nil when Open is false. func PopoverContent(p PopoverContentProps, children ...*vdom.VNode) *vdom.VNode { if !p.Open { return nil } mods := []vdom.Mod{ vdom.Attr("role", "menu"), vdom.Attr("data-floating-content", "true"), vdom.Attr("class", cx("absolute z-[110]", popoverPlacementCls(p.Placement), popoverCls, p.Class)), } return vdom.El("div", kids(mods, children)...) } // HoverPopoverProps configures HoverPopover. Placement defaults to // "bottom-start". type HoverPopoverProps struct { Placement string Class string } // HoverPopover is the hover-driven wrapper. It carries the Tailwind `group` // marker so HoverPopoverContent can reveal itself on hover purely in CSS. func HoverPopover(p HoverPopoverProps, children ...*vdom.VNode) *vdom.VNode { return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("group relative inline-block", p.Class))}, children)...) } // HoverPopoverTriggerProps configures HoverPopoverTrigger. OnMouseEnter / // OnMouseLeave mirror the TSX hover wiring (optional; hover reveal itself is CSS). type HoverPopoverTriggerProps struct { OnMouseEnter func() OnMouseLeave func() Class string } // HoverPopoverTrigger is the hover target. func HoverPopoverTrigger(p HoverPopoverTriggerProps, children ...*vdom.VNode) *vdom.VNode { mods := []vdom.Mod{vdom.Attr("type", "button"), vdom.Attr("class", p.Class)} if p.OnMouseEnter != nil { mods = append(mods, vdom.On("mouseenter", p.OnMouseEnter)) } if p.OnMouseLeave != nil { mods = append(mods, vdom.On("mouseleave", p.OnMouseLeave)) } return vdom.El("button", kids(mods, children)...) } // HoverPopoverContentProps configures HoverPopoverContent. type HoverPopoverContentProps struct { Placement string Class string OnMouseEnter func() OnMouseLeave func() } // HoverPopoverContent is the hover panel: hidden by default and revealed while // the surrounding HoverPopover (group) is hovered. func HoverPopoverContent(p HoverPopoverContentProps, children ...*vdom.VNode) *vdom.VNode { const reveal = "invisible opacity-0 transition-opacity group-hover:visible group-hover:opacity-100" mods := []vdom.Mod{ vdom.Attr("role", "menu"), vdom.Attr("data-floating-content", "true"), vdom.Attr("class", cx("absolute z-[110]", popoverPlacementCls(p.Placement), popoverCls, reveal, p.Class)), } if p.OnMouseEnter != nil { mods = append(mods, vdom.On("mouseenter", p.OnMouseEnter)) } if p.OnMouseLeave != nil { mods = append(mods, vdom.On("mouseleave", p.OnMouseLeave)) } return vdom.El("div", kids(mods, children)...) }