Files
kjol/go/webui/floating.go

190 lines
7.0 KiB
Go

package webui
import "kjol/vdom"
// Port of web/kit/Floating.tsx.
//
// The TSX is a floating-ui-style popover kit built on Solid context, refs,
// getBoundingClientRect, requestAnimationFrame, window scroll/resize listeners,
// document-level mousedown/keydown handlers, a Portal to document.body, and a
// global single-open FloatingManager. None of that has an equivalent in the
// neutral vdom runtime, so this port keeps the API shape (Root / Trigger /
// Content) plus the Tailwind, and models open state as a plain value + callback.
//
// NOTE: All computed positioning (calculatePosition, flip, shift, offset px,
// getBoundingClientRect, the position() signal, window scroll/resize reflow) is
// dropped. FloatingContent is approximated with a statically-positioned
// `absolute` element anchored to FloatingRoot's `relative` container, placed via
// Tailwind utilities chosen from the Placement value. The original rendered the
// content through a Portal with `position: fixed`; here it stays in-flow.
// NOTE: The global single-open FloatingManager, the open-order stack
// (openFloatings), outside-click dismissal, Escape-to-close, and hover-open /
// hover-close timers are dropped — callers own open state and decide when to
// toggle it. useFloatingContext / FloatingContextValue and the useFloatingHover
// hook are dropped (no Solid context; nothing to share through).
// Placement values (subset of CSS anchor placements) understood by
// FloatingContent's static positioning approximation.
const (
PlacementTop = "top"
PlacementTopStart = "top-start"
PlacementTopEnd = "top-end"
PlacementBottom = "bottom"
PlacementBottomStart = "bottom-start"
PlacementBottomEnd = "bottom-end"
PlacementLeft = "left"
PlacementLeftStart = "left-start"
PlacementLeftEnd = "left-end"
PlacementRight = "right"
PlacementRightStart = "right-start"
PlacementRightEnd = "right-end"
)
// PositionOptions mirrors the TSX PositionOptions. Retained for API parity; in
// this port only Placement influences rendering (see the file-level NOTE) — the
// numeric/flip/shift fields are not consumed because there is no measurement.
type PositionOptions struct {
Placement string
Offset int
Flip bool
Shift bool
ShiftPadding int
}
// floatingPlacementClass maps a Placement to Tailwind utilities that position an
// `absolute` child relative to its `relative` FloatingRoot container. The ~4px
// default offset is approximated with the mt-1/mb-1/ml-1/mr-1 gap classes.
func floatingPlacementClass(placement string) string {
switch placement {
case PlacementTop:
return "bottom-full left-1/2 -translate-x-1/2 mb-1"
case PlacementTopStart:
return "bottom-full left-0 mb-1"
case PlacementTopEnd:
return "bottom-full right-0 mb-1"
case PlacementBottom:
return "top-full left-1/2 -translate-x-1/2 mt-1"
case PlacementBottomEnd:
return "top-full right-0 mt-1"
case PlacementLeft:
return "right-full top-1/2 -translate-y-1/2 mr-1"
case PlacementLeftStart:
return "right-full top-0 mr-1"
case PlacementLeftEnd:
return "right-full bottom-0 mr-1"
case PlacementRight:
return "left-full top-1/2 -translate-y-1/2 ml-1"
case PlacementRightStart:
return "left-full top-0 ml-1"
case PlacementRightEnd:
return "left-full bottom-0 ml-1"
default: // PlacementBottomStart and unknown values
return "top-full left-0 mt-1"
}
}
// FloatingRootProps configures FloatingRoot. Open is the current open state
// (caller-held); OnOpenChange is invoked by children that toggle it. The
// numeric/flip/shift/standalone fields are retained for API parity but are not
// used by the static-positioning approximation (see the file-level NOTE).
type FloatingRootProps struct {
Open bool
OnOpenChange func(bool)
Placement string
Offset int
Flip bool
Shift bool
ShiftPadding int
Standalone bool
Class string
}
// FloatingRoot wraps a Trigger + Content pair. The TSX component rendered no DOM
// node (only a context provider); this port emits a `relative inline-block`
// container so the absolutely-positioned FloatingContent has an anchor.
func FloatingRoot(p FloatingRootProps, children ...*vdom.VNode) *vdom.VNode {
return vdom.El("div", kids([]vdom.Mod{
vdom.Attr("class", cx("relative inline-block", p.Class)),
}, children)...)
}
// FloatingTriggerProps configures FloatingTrigger. Open feeds aria-expanded;
// OnToggle fires on click. OpenOnHover/HoverDelay/HoverCloseDelay are retained
// for API parity but are inert here (hover timers dropped).
type FloatingTriggerProps struct {
Open bool
OnToggle func()
OpenOnHover bool
HoverDelay int
HoverCloseDelay int
Class string
Title string
}
// FloatingTrigger renders the <button> that toggles the floating content.
//
// NOTE: keyboard activation (Enter/Space to toggle, Escape to close) and
// hover-open/hover-close behavior are dropped — the vdom Event exposes no key,
// and there are no timers. Click toggling via OnToggle is preserved.
func FloatingTrigger(p FloatingTriggerProps, children ...*vdom.VNode) *vdom.VNode {
ariaExpanded := "false"
if p.Open {
ariaExpanded = "true"
}
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", p.Class),
vdom.Attr("aria-expanded", ariaExpanded),
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)...)
}
// FloatingContentProps configures FloatingContent. Open toggles visibility;
// Placement picks the static position (see floatingPlacementClass). Style is an
// optional extra inline-style passthrough. OnMouseEnter/OnMouseLeave are wired
// (used by hover popovers to keep themselves open), though the close timer they
// fed in the TSX is gone.
type FloatingContentProps struct {
Open bool
Placement string
Class string
Style string
OnMouseEnter func()
OnMouseLeave func()
}
// FloatingContent renders the popover panel (role="menu"). It is hidden via the
// `hidden` utility while closed rather than unmounted.
//
// NOTE: rendered in-flow as an `absolute` element instead of portaled to
// document.body with computed `position: fixed` coordinates; z-[110] preserves
// the TSX's stacking intent (above a z-[100] modal container).
func FloatingContent(p FloatingContentProps, children ...*vdom.VNode) *vdom.VNode {
vis := "hidden"
if p.Open {
vis = "block"
}
mods := []vdom.Mod{
vdom.Attr("role", "menu"),
vdom.Attr("data-floating-content", "true"),
vdom.Attr("class", cx("absolute z-[110]", floatingPlacementClass(p.Placement), vis, p.Class)),
}
if p.Style != "" {
mods = append(mods, vdom.Attr("style", p.Style))
}
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)...)
}