190 lines
7.0 KiB
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)...)
|
|
}
|