Add fonts, autotable, autotable examples
This commit is contained in:
@@ -1,189 +1,535 @@
|
||||
package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
// 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"
|
||||
"kjol/vdom"
|
||||
"kjol/wasmruntime"
|
||||
)
|
||||
|
||||
// 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
|
||||
// Floating is the live controller for one floating panel — the Go answer to the
|
||||
// TSX kit's Solid context (which Go has no equivalent of). Tooltips, popovers,
|
||||
// menus, submenus and the date picker's dropdown are all built on it.
|
||||
//
|
||||
// The dance it performs, and why each step exists:
|
||||
//
|
||||
// 1. Show() flips a signal, which schedules a render. The panel is rendered
|
||||
// PORTALED to document.body (so an ancestor's overflow:hidden or transform
|
||||
// cannot clip it or re-root its `position: fixed`) and laid out but INVISIBLE
|
||||
// (`visibility: hidden` — which still takes up layout, unlike `display: none`).
|
||||
// 2. AfterRender fires once the DOM exists. Only now can the panel be measured:
|
||||
// you cannot know where to put a panel until you know how big it is.
|
||||
// 3. ComputePosition does the math; the result is written with SetStyle —
|
||||
// imperatively, straight to the DOM node. NOT through a signal: a signal write
|
||||
// re-renders the entire app, and this runs on every scroll frame.
|
||||
// 4. The panel is revealed. It never paints at 0,0.
|
||||
//
|
||||
// Create one per floating element, alongside your signals — NOT inside a render
|
||||
// function, which would rebuild it (and its refs) every frame:
|
||||
//
|
||||
// menu := webui.NewFloating(webui.FloatingOptions{Placement: webui.PlacementBottomEnd})
|
||||
// return func() *vdom.VNode {
|
||||
// return Div(
|
||||
// menu.Trigger(webui.FloatingTriggerProps{}, Text("Actions")),
|
||||
// menu.Panel(webui.FloatingPanelProps{}, items...),
|
||||
// )
|
||||
// }
|
||||
type Floating struct {
|
||||
id string
|
||||
opts FloatingOptions
|
||||
|
||||
open *vdom.Signal[bool]
|
||||
resolved *vdom.Signal[string] // placement after flipping; the arrow's side depends on it
|
||||
|
||||
triggerRef *vdom.Ref
|
||||
panelRef *vdom.Ref
|
||||
arrowRef *vdom.Ref
|
||||
|
||||
unsubs []wasmruntime.Unsub
|
||||
hoverTimer 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"
|
||||
}
|
||||
}
|
||||
// FloatingOptions configures a Floating. The zero value is usable: bottom-start,
|
||||
// 4px offset, flip + shift on, closes on outside click and Escape.
|
||||
type FloatingOptions struct {
|
||||
Placement string
|
||||
Offset float64
|
||||
Padding float64
|
||||
NoFlip bool // inverted so the zero value means "flip", which is what you want
|
||||
NoShift bool
|
||||
|
||||
// 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
|
||||
}
|
||||
// ArrowSize is the arrow's width/height in px. Zero means no arrow.
|
||||
ArrowSize float64
|
||||
ArrowPadding float64
|
||||
|
||||
// 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)...)
|
||||
}
|
||||
// Standalone opts out of the single-open manager: opening this panel will not
|
||||
// close others, and others will not close it. Nested floatings (a submenu
|
||||
// inside a menu, a select inside a popover) must set it, or opening the child
|
||||
// would close its own parent.
|
||||
Standalone bool
|
||||
|
||||
// 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()
|
||||
KeepOnOutsideClick bool // inverted: by default an outside mousedown closes
|
||||
KeepOnEscape bool // inverted: by default Escape closes the topmost
|
||||
|
||||
// ConstrainToViewport caps the panel's size on the main axis to the room
|
||||
// actually available, so a long menu scrolls instead of running off screen.
|
||||
ConstrainToViewport bool
|
||||
|
||||
// OpenOnHover turns the trigger into a hover target. HoverDelay is how long the
|
||||
// cursor must rest before opening; HoverCloseDelay is the grace period after
|
||||
// leaving — the "bridge" that lets the cursor cross the gap onto the panel
|
||||
// without it vanishing. Defaults: 0 and 150ms.
|
||||
OpenOnHover bool
|
||||
HoverDelay int
|
||||
HoverCloseDelay int
|
||||
Class string
|
||||
Title string
|
||||
|
||||
OnOpenChange func(bool)
|
||||
}
|
||||
|
||||
// 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"
|
||||
var floatingSeq int
|
||||
|
||||
// NewFloating creates a floating controller. Call it once per panel, outside the
|
||||
// render function.
|
||||
func NewFloating(o FloatingOptions) *Floating {
|
||||
floatingSeq++
|
||||
if o.Placement == "" {
|
||||
o.Placement = PlacementBottomStart
|
||||
}
|
||||
if o.Offset == 0 {
|
||||
o.Offset = 4
|
||||
}
|
||||
if o.Padding == 0 {
|
||||
o.Padding = 8
|
||||
}
|
||||
if o.ArrowPadding == 0 {
|
||||
o.ArrowPadding = 4
|
||||
}
|
||||
if o.HoverCloseDelay == 0 {
|
||||
o.HoverCloseDelay = 150
|
||||
}
|
||||
return &Floating{
|
||||
id: "floating-" + strconv.Itoa(floatingSeq),
|
||||
opts: o,
|
||||
open: vdom.NewSignal(false),
|
||||
resolved: vdom.NewSignal(o.Placement),
|
||||
triggerRef: vdom.NewRef(),
|
||||
panelRef: vdom.NewRef(),
|
||||
arrowRef: vdom.NewRef(),
|
||||
}
|
||||
}
|
||||
|
||||
// IsOpen reports the current state. Safe to read during render.
|
||||
func (f *Floating) IsOpen() bool { return f.open.Get() }
|
||||
|
||||
// Placement is the resolved placement — after any flip. Read it during render to
|
||||
// decide which side an arrow or a transition origin belongs on.
|
||||
func (f *Floating) Placement() string { return f.resolved.Get() }
|
||||
|
||||
// Toggle, Show and Hide drive the panel. They are safe to call from event handlers.
|
||||
func (f *Floating) Toggle() {
|
||||
if f.open.Get() {
|
||||
f.Hide()
|
||||
} else {
|
||||
f.Show()
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Floating) Show() {
|
||||
if f.open.Get() {
|
||||
return
|
||||
}
|
||||
f.cancelHover()
|
||||
registerOpen(f)
|
||||
f.open.Set(true)
|
||||
if f.opts.OnOpenChange != nil {
|
||||
f.opts.OnOpenChange(true)
|
||||
}
|
||||
// The panel does not exist yet — the signal write only *scheduled* a render.
|
||||
// Measure once it does.
|
||||
wasmruntime.AfterRender(f.mounted)
|
||||
}
|
||||
|
||||
func (f *Floating) Hide() {
|
||||
if !f.open.Get() {
|
||||
return
|
||||
}
|
||||
f.cancelHover()
|
||||
f.teardown()
|
||||
unregisterOpen(f)
|
||||
f.open.Set(false)
|
||||
if f.opts.OnOpenChange != nil {
|
||||
f.opts.OnOpenChange(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Dispose closes the panel and removes every listener. Call it if the component
|
||||
// owning this Floating goes away while the panel might still be open.
|
||||
func (f *Floating) Dispose() {
|
||||
f.cancelHover()
|
||||
f.teardown()
|
||||
unregisterOpen(f)
|
||||
if f.open.Get() {
|
||||
f.open.Set(false)
|
||||
}
|
||||
}
|
||||
|
||||
// mounted runs once the panel is in the DOM: position it, then start tracking.
|
||||
func (f *Floating) mounted() {
|
||||
f.Reposition()
|
||||
|
||||
// Reposition when anything that feeds the math changes. `scroll` is registered
|
||||
// with capture=true because scroll does NOT bubble — capture on window is the
|
||||
// only way to hear a scroll inside a nested container (a panel anchored to a row
|
||||
// in a scrollable table depends on this).
|
||||
f.unsubs = append(f.unsubs,
|
||||
wasmruntime.OnWindow(vdom.EVENT_SCROLL, true, func(vdom.Event) { f.Reposition() }),
|
||||
wasmruntime.OnWindow(vdom.EVENT_RESIZE, false, func(vdom.Event) { f.Reposition() }),
|
||||
// The original kit repositioned only on scroll/resize, so a panel whose own
|
||||
// content changed size (an async list, a filtered dropdown) stayed at its
|
||||
// stale position. This is the fix.
|
||||
wasmruntime.ObserveResize(f.panelRef, f.Reposition),
|
||||
)
|
||||
|
||||
if !f.opts.KeepOnOutsideClick {
|
||||
// mousedown, not click: it fires before focus moves, so a click that both
|
||||
// closes this panel and focuses something else behaves predictably.
|
||||
f.unsubs = append(f.unsubs, wasmruntime.OnDocument(vdom.EVENT_MOUSEDOWN, false, f.onOutside))
|
||||
}
|
||||
if !f.opts.KeepOnEscape {
|
||||
f.unsubs = append(f.unsubs, wasmruntime.OnDocument(vdom.EVENT_KEYDOWN, false, f.onKeydown))
|
||||
}
|
||||
}
|
||||
|
||||
// Reposition re-runs the math against the live DOM. Cheap enough to call on every
|
||||
// scroll frame: two measurements, some arithmetic, and a couple of style writes —
|
||||
// no re-render.
|
||||
func (f *Floating) Reposition() {
|
||||
if !f.open.Get() {
|
||||
return
|
||||
}
|
||||
// Self-heal: if the panel was torn out from under us (a route change re-rendered
|
||||
// the page away), stop tracking rather than leaking listeners forever.
|
||||
if !f.panelRef.Mounted() {
|
||||
f.teardown()
|
||||
return
|
||||
}
|
||||
|
||||
trigger := wasmruntime.Measure(f.triggerRef)
|
||||
panel := wasmruntime.Measure(f.panelRef)
|
||||
if trigger.Empty() || panel.Empty() {
|
||||
return // not laid out yet; stay hidden rather than paint at 0,0
|
||||
}
|
||||
|
||||
pos := ComputePosition(trigger, panel, wasmruntime.Viewport(), f.positionOptions())
|
||||
|
||||
wasmruntime.SetStyle(f.panelRef, "top", px(pos.Top))
|
||||
wasmruntime.SetStyle(f.panelRef, "left", px(pos.Left))
|
||||
if f.opts.ConstrainToViewport {
|
||||
if pos.MaxHeight > 0 {
|
||||
wasmruntime.SetStyle(f.panelRef, "max-height", px(pos.MaxHeight))
|
||||
wasmruntime.SetStyle(f.panelRef, "overflow-y", "auto")
|
||||
}
|
||||
if pos.MaxWidth > 0 {
|
||||
wasmruntime.SetStyle(f.panelRef, "max-width", px(pos.MaxWidth))
|
||||
}
|
||||
}
|
||||
wasmruntime.SetStyle(f.panelRef, "visibility", "visible")
|
||||
|
||||
if f.opts.ArrowSize > 0 && f.arrowRef.Mounted() {
|
||||
base, _ := splitPlacement(pos.Placement)
|
||||
if base == "top" || base == "bottom" {
|
||||
wasmruntime.SetStyle(f.arrowRef, "left", px(pos.ArrowOffset))
|
||||
wasmruntime.RemoveStyle(f.arrowRef, "top")
|
||||
} else {
|
||||
wasmruntime.SetStyle(f.arrowRef, "top", px(pos.ArrowOffset))
|
||||
wasmruntime.RemoveStyle(f.arrowRef, "left")
|
||||
}
|
||||
}
|
||||
|
||||
// The arrow's SIDE (which edge it hangs off) is a class, not a style, so a flip
|
||||
// has to go through a render. Guarded on change, so this converges after one
|
||||
// extra render instead of looping.
|
||||
if f.resolved.Get() != pos.Placement {
|
||||
f.resolved.Set(pos.Placement)
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Floating) positionOptions() PositionOptions {
|
||||
return PositionOptions{
|
||||
Placement: f.opts.Placement,
|
||||
Offset: f.opts.Offset,
|
||||
Flip: !f.opts.NoFlip,
|
||||
Shift: !f.opts.NoShift,
|
||||
Padding: f.opts.Padding,
|
||||
ArrowSize: f.opts.ArrowSize,
|
||||
ArrowPadding: f.opts.ArrowPadding,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Floating) teardown() {
|
||||
for _, un := range f.unsubs {
|
||||
un()
|
||||
}
|
||||
f.unsubs = nil
|
||||
}
|
||||
|
||||
// onOutside closes the panel on a mousedown that landed outside it — unless it
|
||||
// landed inside a floating that opened LATER, which makes that floating a
|
||||
// descendant of this one (a submenu of this menu, a select inside this popover).
|
||||
// Clicking an ANCESTOR still closes this panel, which is what you want.
|
||||
func (f *Floating) onOutside(ev vdom.Event) {
|
||||
target := ev.Target()
|
||||
if wasmruntime.Contains(f.panelRef, target) || wasmruntime.Contains(f.triggerRef, target) {
|
||||
return
|
||||
}
|
||||
if id, ok := wasmruntime.ClosestAttr(target, "[data-floating-id]", "data-floating-id"); ok && openedAfter(id, f.id) {
|
||||
return
|
||||
}
|
||||
f.Hide()
|
||||
}
|
||||
|
||||
// onKeydown closes on Escape — but only the topmost panel, so a menu inside a
|
||||
// popover closes one layer per press instead of collapsing the whole stack.
|
||||
func (f *Floating) onKeydown(ev vdom.Event) {
|
||||
if ev.Key() != vdom.KEY_ESCAPE || !isTopmost(f) {
|
||||
return
|
||||
}
|
||||
ev.PreventDefault()
|
||||
f.Hide()
|
||||
}
|
||||
|
||||
// ---- hover bridge ----
|
||||
|
||||
// The gap between a trigger and its panel is dead space: without a grace period,
|
||||
// moving the cursor across it closes the panel before you arrive. Both the trigger
|
||||
// and the panel cancel the pending close on enter and reschedule it on leave, so
|
||||
// the cursor can travel between them.
|
||||
|
||||
func (f *Floating) hoverEnter() {
|
||||
f.cancelHover()
|
||||
if f.open.Get() {
|
||||
return
|
||||
}
|
||||
if f.opts.HoverDelay <= 0 {
|
||||
f.Show()
|
||||
return
|
||||
}
|
||||
f.hoverTimer = wasmruntime.SetTimeout(f.opts.HoverDelay, func() {
|
||||
f.hoverTimer = 0
|
||||
f.Show()
|
||||
})
|
||||
}
|
||||
|
||||
func (f *Floating) hoverLeave() {
|
||||
f.cancelHover()
|
||||
f.hoverTimer = wasmruntime.SetTimeout(f.opts.HoverCloseDelay, func() {
|
||||
f.hoverTimer = 0
|
||||
f.Hide()
|
||||
})
|
||||
}
|
||||
|
||||
func (f *Floating) cancelHover() {
|
||||
if f.hoverTimer != 0 {
|
||||
wasmruntime.ClearTimeout(f.hoverTimer)
|
||||
f.hoverTimer = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ---- the single-open manager + open-order stack ----
|
||||
|
||||
var (
|
||||
// floatingStack is every open floating, in the order they opened. Order is what
|
||||
// makes "close only the topmost on Escape" and the descendant test above work.
|
||||
floatingStack []*Floating
|
||||
// currentSingle is the one non-standalone floating allowed open at a time.
|
||||
currentSingle *Floating
|
||||
)
|
||||
|
||||
func registerOpen(f *Floating) {
|
||||
if !f.opts.Standalone && currentSingle != nil && currentSingle != f {
|
||||
currentSingle.Hide()
|
||||
}
|
||||
floatingStack = append(floatingStack, f)
|
||||
if !f.opts.Standalone {
|
||||
currentSingle = f
|
||||
}
|
||||
}
|
||||
|
||||
func unregisterOpen(f *Floating) {
|
||||
for i, o := range floatingStack {
|
||||
if o == f {
|
||||
floatingStack = append(floatingStack[:i], floatingStack[i+1:]...)
|
||||
break
|
||||
}
|
||||
}
|
||||
if currentSingle == f {
|
||||
currentSingle = nil
|
||||
}
|
||||
}
|
||||
|
||||
func isTopmost(f *Floating) bool {
|
||||
return len(floatingStack) > 0 && floatingStack[len(floatingStack)-1] == f
|
||||
}
|
||||
|
||||
// openedAfter reports whether the floating with the given id opened later than
|
||||
// self — i.e. is nested inside it.
|
||||
func openedAfter(id, selfID string) bool {
|
||||
selfIdx := -1
|
||||
for i, o := range floatingStack {
|
||||
if o.id == selfID {
|
||||
selfIdx = i
|
||||
break
|
||||
}
|
||||
}
|
||||
if selfIdx < 0 {
|
||||
return false
|
||||
}
|
||||
for _, o := range floatingStack[selfIdx+1:] {
|
||||
if o.id == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// ---- rendering ----
|
||||
|
||||
// FloatingTriggerProps configures the element that opens the panel.
|
||||
type FloatingTriggerProps struct {
|
||||
Class string
|
||||
Title string
|
||||
Tag string // default "button"
|
||||
AriaHasPopup string
|
||||
// OnClick runs in addition to the toggle (which is suppressed when the trigger
|
||||
// opens on hover).
|
||||
OnClick func()
|
||||
}
|
||||
|
||||
// Trigger renders the element the panel is anchored to.
|
||||
func (f *Floating) Trigger(p FloatingTriggerProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
expanded := "false"
|
||||
if f.open.Get() {
|
||||
expanded = "true"
|
||||
}
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.WithRef(f.triggerRef),
|
||||
vdom.Attr("class", p.Class),
|
||||
vdom.Attr("aria-expanded", ariaExpanded),
|
||||
vdom.Attr("aria-haspopup", "menu"),
|
||||
vdom.Attr("aria-expanded", expanded),
|
||||
vdom.Attr("aria-haspopup", pick(p.AriaHasPopup, "menu")),
|
||||
}
|
||||
tag := pick(p.Tag, "button")
|
||||
if tag == "button" {
|
||||
mods = append(mods, vdom.Attr("type", "button"))
|
||||
}
|
||||
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))
|
||||
|
||||
if f.opts.OpenOnHover {
|
||||
mods = append(mods,
|
||||
vdom.On(vdom.EVENT_MOUSEENTER, f.hoverEnter),
|
||||
vdom.On(vdom.EVENT_MOUSELEAVE, f.hoverLeave),
|
||||
// focus opens it too, so the panel is reachable from the keyboard.
|
||||
vdom.On(vdom.EVENT_FOCUSIN, f.Show),
|
||||
vdom.On(vdom.EVENT_FOCUSOUT, f.hoverLeave),
|
||||
)
|
||||
if p.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
|
||||
}
|
||||
} else {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() {
|
||||
if p.OnClick != nil {
|
||||
p.OnClick()
|
||||
}
|
||||
f.Toggle()
|
||||
}))
|
||||
}
|
||||
return vdom.El("button", kids(mods, children)...)
|
||||
|
||||
// Enter/Space activate a non-button trigger; Escape closes from the trigger.
|
||||
mods = append(mods, vdom.OnEvent(vdom.EVENT_KEYDOWN, func(ev vdom.Event) {
|
||||
switch ev.Key() {
|
||||
case vdom.KEY_ENTER, vdom.KEY_SPACE:
|
||||
if tag != "button" { // a real button already fires click on Enter/Space
|
||||
ev.PreventDefault()
|
||||
f.Toggle()
|
||||
}
|
||||
case vdom.KEY_ESCAPE:
|
||||
f.Hide()
|
||||
}
|
||||
}))
|
||||
|
||||
return vdom.El(tag, 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()
|
||||
// FloatingPanelProps configures the floating panel.
|
||||
type FloatingPanelProps struct {
|
||||
Class string
|
||||
Role string // default "menu"
|
||||
}
|
||||
|
||||
// FloatingContent renders the popover panel (role="menu"). It is hidden via the
|
||||
// `hidden` utility while closed rather than unmounted.
|
||||
// Panel renders the floating content, portaled to document.body.
|
||||
//
|
||||
// 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"
|
||||
// It is rendered `position: fixed` at 0,0 with `visibility: hidden` — laid out (so
|
||||
// it can be measured) but not painted. Reposition then writes the real coordinates
|
||||
// and reveals it. The inline style string is IDENTICAL on every render, which is
|
||||
// what stops the reconciler's attribute diff from clobbering the imperative
|
||||
// positions: it only calls setAttribute when the declared value changes.
|
||||
//
|
||||
// When closed it renders an EMPTY portal rather than nothing, so its slot in the
|
||||
// parent's child list never disappears — the reconciler diffs children by index,
|
||||
// and a vanishing child would shift every sibling after it.
|
||||
func (f *Floating) Panel(p FloatingPanelProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
if !f.open.Get() {
|
||||
return vdom.Portal()
|
||||
}
|
||||
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)),
|
||||
vdom.WithRef(f.panelRef),
|
||||
vdom.Attr("data-floating-id", f.id),
|
||||
vdom.Attr("role", pick(p.Role, "menu")),
|
||||
vdom.Attr("class", cx("z-[110]", p.Class)),
|
||||
vdom.Attr("style", "position:fixed;top:0;left:0;visibility:hidden"),
|
||||
}
|
||||
if p.Style != "" {
|
||||
mods = append(mods, vdom.Attr("style", p.Style))
|
||||
if f.opts.OpenOnHover {
|
||||
mods = append(mods,
|
||||
vdom.On(vdom.EVENT_MOUSEENTER, f.cancelHover),
|
||||
vdom.On(vdom.EVENT_MOUSELEAVE, f.hoverLeave),
|
||||
)
|
||||
}
|
||||
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)...)
|
||||
return vdom.Portal(vdom.Div(kids(mods, children)...))
|
||||
}
|
||||
|
||||
// Arrow renders the little triangle that points at the trigger. Its side comes
|
||||
// from the RESOLVED placement, so it follows the panel through a flip; its offset
|
||||
// along that side is written imperatively by Reposition, so it keeps pointing at
|
||||
// the trigger even after the panel has been shifted away from it.
|
||||
//
|
||||
// Requires FloatingOptions.ArrowSize to be set.
|
||||
func (f *Floating) Arrow(class string) *vdom.VNode {
|
||||
side := OppositeSide(f.resolved.Get())
|
||||
size := px(f.opts.ArrowSize)
|
||||
|
||||
// The arrow is a rotated square pinned to the panel's edge; the translate pulls
|
||||
// it half its own size outside, and centres it on the offset Reposition writes.
|
||||
var edge, translate string
|
||||
switch side {
|
||||
case "top":
|
||||
edge, translate = "top:0;", "translate(-50%, -50%) rotate(45deg)"
|
||||
case "bottom":
|
||||
edge, translate = "bottom:0;", "translate(-50%, 50%) rotate(45deg)"
|
||||
case "left":
|
||||
edge, translate = "left:0;", "translate(-50%, -50%) rotate(45deg)"
|
||||
default: // right
|
||||
edge, translate = "right:0;", "translate(50%, -50%) rotate(45deg)"
|
||||
}
|
||||
style := "position:absolute;" + edge +
|
||||
"width:" + size + ";height:" + size + ";transform:" + translate + ";"
|
||||
|
||||
return vdom.Div(vdom.WithRef(f.arrowRef),
|
||||
vdom.Attr("class", cx("pointer-events-none", class)),
|
||||
vdom.Attr("style", style),
|
||||
)
|
||||
}
|
||||
|
||||
func px(v float64) string {
|
||||
return strconv.FormatFloat(v, 'f', -1, 64) + "px"
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user