initial port of the UI kit

This commit is contained in:
2026-07-13 01:58:29 -04:00
parent 261a7f2b4d
commit b02df48a66
51 changed files with 7507 additions and 154 deletions

165
go/webui/popovers.go Normal file
View File

@@ -0,0 +1,165 @@
// 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)...)
}