package webui import ( "strings" "kjol/wasmruntime" ) // The floating-position engine: given where the trigger is, how big the panel is, // and how big the viewport is, work out where to put the panel. Pure math over // structs — no DOM, no browser — so it tests natively and runs identically on the // server (where it simply never gets called, because nothing is open during SSR). // // This replaces the hand-rolled positioning that the TSX kit had in four divergent // copies (Floating.tsx, Tutorial.tsx, Menu's Submenu, DatePicker's dropdown). It // deliberately FIXES three flaws in the original rather than reproducing them: // // 1. The original shifted on BOTH axes, so a tall panel that did not fit got // clamped up into its own trigger — covering the thing you clicked. Shift here // is cross-axis only (what floating-ui does): the main axis is the flip's job. // 2. The original had no arrow offset, so a shifted panel kept its arrow pinned to // its own centre and the arrow visibly detached from the trigger. Here the // arrow tracks the trigger's centre, clamped to stay on the panel. // 3. The original could not tell a caller how much room there actually was, so a // long menu near the viewport edge just overflowed. MaxHeight / MaxWidth report // the space available on the main axis so the panel can scroll instead. // // All coordinates are viewport coordinates (getBoundingClientRect + position:fixed // speak the same language), so there is no scroll compensation anywhere. Adding // scrollX/scrollY here would be a bug, not a fix. // Placement values: a base side, optionally with a cross-axis alignment. 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 tunes ComputePosition. The zero value is not useful; use // DefaultPositionOptions and override. type PositionOptions struct { Placement string // one of the Placement* constants; default bottom-start Offset float64 // gap between trigger and panel, in px Flip bool // move to the opposite side when this side does not fit Shift bool // slide along the cross axis to stay in the viewport Padding float64 // keep this far from the viewport edge (flip fit-check + shift clamp) // ArrowSize is the arrow's full width/height in px. Zero means no arrow, and // ArrowOffset is left at 0. ArrowSize float64 // ArrowPadding keeps the arrow this far from the panel's corners, so it never // pokes out of a rounded edge. ArrowPadding float64 } // DefaultPositionOptions matches the TSX defaults (bottom-start, 4px offset, flip // and shift on, 8px viewport padding). func DefaultPositionOptions() PositionOptions { return PositionOptions{ Placement: PlacementBottomStart, Offset: 4, Flip: true, Shift: true, Padding: 8, ArrowPadding: 4, } } // Position is where to put the panel, in viewport coordinates. type Position struct { Top, Left float64 // Placement is the RESOLVED placement — after any flip. This is consumer-visible: // an arrow must be drawn on the side opposite the resolved base, not the // requested one, or a flipped tooltip points away from what it describes. Placement string // ArrowOffset is the arrow's centre along the panel's cross axis, measured from // the panel's own top-left: an x for top/bottom placements, a y for left/right. // Zero when ArrowSize is 0. ArrowOffset float64 // MaxHeight / MaxWidth are the space available on the main axis at the resolved // placement, for a panel that should scroll rather than overflow. Only the // main-axis one is set; the other is 0, meaning unconstrained. MaxHeight, MaxWidth float64 } // ComputePosition places `floating` against `trigger` inside `vp`. // // The caller must have measured a real, laid-out panel: to know where a panel goes // you must first know how big it is, which means rendering it (invisibly) and // measuring it. See Floating.reposition, which does exactly that dance. func ComputePosition(trigger, floating wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions) Position { if o.Placement == "" { o.Placement = PlacementBottomStart } base, align := splitPlacement(o.Placement) top, left := mainAxis(base, trigger, floating, o.Offset) crossAxis(base, align, trigger, floating, &top, &left) if o.Flip { base = flip(base, top, left, trigger, floating, vp, o, &top, &left) } if o.Shift { shiftCrossAxis(base, floating, vp, o.Padding, &top, &left) } pos := Position{Top: top, Left: left, Placement: joinPlacement(base, align)} pos.MaxHeight, pos.MaxWidth = available(base, trigger, vp, o) if o.ArrowSize > 0 { pos.ArrowOffset = arrowOffset(base, trigger, floating, top, left, o) } return pos } // mainAxis positions the panel on the axis it is offset along — the only axis the // base side controls. func mainAxis(base string, t, f wasmruntime.Rect, offset float64) (top, left float64) { switch base { case "top": top = t.Top() - f.Height - offset case "bottom": top = t.Bottom() + offset case "left": left = t.Left() - f.Width - offset case "right": left = t.Right() + offset } return } // crossAxis aligns the panel across the base side: start/center/end. func crossAxis(base, align string, t, f wasmruntime.Rect, top, left *float64) { if base == "top" || base == "bottom" { switch align { case "start": *left = t.Left() case "end": *left = t.Right() - f.Width default: *left = t.CenterX() - f.Width/2 } return } switch align { case "start": *top = t.Top() case "end": *top = t.Bottom() - f.Height default: *top = t.CenterY() - f.Height/2 } } // flip moves the panel to the opposite side when it does not fit on this one — but // only if the opposite side actually fits. If neither side fits we keep the // original: flipping into an equally bad position just makes it harder to predict. // The cross-axis alignment is preserved (bottom-start flips to top-start). func flip(base string, top, left float64, t, f wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions, outTop, outLeft *float64) string { pad := o.Padding fits := func(start, size, limit float64) bool { return start >= pad && start+size <= limit-pad } switch base { case "bottom": if !fits(top, f.Height, vp.Height) { if alt := t.Top() - f.Height - o.Offset; fits(alt, f.Height, vp.Height) { *outTop = alt return "top" } } case "top": if !fits(top, f.Height, vp.Height) { if alt := t.Bottom() + o.Offset; fits(alt, f.Height, vp.Height) { *outTop = alt return "bottom" } } case "right": if !fits(left, f.Width, vp.Width) { if alt := t.Left() - f.Width - o.Offset; fits(alt, f.Width, vp.Width) { *outLeft = alt return "left" } } case "left": if !fits(left, f.Width, vp.Width) { if alt := t.Right() + o.Offset; fits(alt, f.Width, vp.Width) { *outLeft = alt return "right" } } } return base } // shiftCrossAxis slides the panel along the CROSS axis to keep it on screen. It // deliberately does not touch the main axis: clamping there is what let the // original push a panel on top of its own trigger. func shiftCrossAxis(base string, f wasmruntime.Rect, vp wasmruntime.Size, pad float64, top, left *float64) { if base == "top" || base == "bottom" { *left = clamp(*left, pad, vp.Width-f.Width-pad) return } *top = clamp(*top, pad, vp.Height-f.Height-pad) } // available reports the room between the trigger and the viewport edge on the // resolved side, so a panel too big for it can scroll instead of overflowing. func available(base string, t wasmruntime.Rect, vp wasmruntime.Size, o PositionOptions) (maxHeight, maxWidth float64) { gap := o.Offset + o.Padding switch base { case "top": return max(t.Top()-gap, 0), 0 case "bottom": return max(vp.Height-t.Bottom()-gap, 0), 0 case "left": return 0, max(t.Left()-gap, 0) case "right": return 0, max(vp.Width-t.Right()-gap, 0) } return 0, 0 } // arrowOffset points the arrow at the trigger's centre, in panel-local coordinates, // clamped so it stays on the panel even when shift has slid the panel away from the // trigger. When the trigger is entirely off past the panel's edge the arrow sits at // the clamp limit — visibly at the corner, which is the honest answer. func arrowOffset(base string, t, f wasmruntime.Rect, top, left float64, o PositionOptions) float64 { half := o.ArrowSize / 2 if base == "top" || base == "bottom" { lo := o.ArrowPadding + half hi := f.Width - o.ArrowPadding - half return clamp(t.CenterX()-left, lo, max(lo, hi)) } lo := o.ArrowPadding + half hi := f.Height - o.ArrowPadding - half return clamp(t.CenterY()-top, lo, max(lo, hi)) } func splitPlacement(p string) (base, align string) { base, align, found := strings.Cut(p, "-") if !found { align = "center" } switch base { case "top", "bottom", "left", "right": default: base = "bottom" } return base, align } func joinPlacement(base, align string) string { if align == "" || align == "center" { return base } return base + "-" + align } // OppositeSide is the side an arrow lives on: a panel placed above its trigger has // its arrow on the bottom edge, pointing down at it. func OppositeSide(placement string) string { base, _ := splitPlacement(placement) switch base { case "top": return "bottom" case "bottom": return "top" case "left": return "right" default: return "left" } } func clamp(v, lo, hi float64) float64 { if v < lo { return lo } if v > hi { return hi } return v }