223 lines
9.4 KiB
Go
223 lines
9.4 KiB
Go
// Port of jsruntime/uikit/Tooltips.tsx, rebuilt on the Floating controller (floating.go).
|
|
//
|
|
// The first Go port degraded the tooltip into a pure-CSS `group-hover` reveal:
|
|
// static placement classes instead of measurement, no flip/shift, no portal, no
|
|
// open delay, no close grace period, an arrow drawn from the REQUESTED placement
|
|
// (so it pointed the wrong way after a flip — except it could never flip), and a
|
|
// `tabindex="0"` on every wrapper, which put every tooltip in the tab order. All of
|
|
// that is restored/fixed here on top of Floating: the bubble is portaled to
|
|
// document.body, measured, flipped and shifted, opens after 200ms of hover, and
|
|
// stays up for a grace period so the cursor can cross the gap and select text in it.
|
|
//
|
|
// Deliberate deviations from the TSX, and why:
|
|
//
|
|
// - The arrow is Floating.Arrow (a rotated square pinned to the panel's edge whose
|
|
// offset along that edge is written imperatively) rather than the TSX's
|
|
// border-triangle utility classes. Those classes pin the arrow to a fixed
|
|
// fraction of the PANEL (`left-1/2`), so as soon as shift slides the panel away
|
|
// from its trigger the arrow visibly detaches from it. Floating.Arrow tracks the
|
|
// trigger, and takes its SIDE from the RESOLVED placement, so it still points at
|
|
// the trigger after a flip.
|
|
// - One close delay (100ms — the TSX's trigger-leave value) covers both
|
|
// trigger-leave and panel-leave; the TSX used 50ms for panel-leave. Floating owns
|
|
// the hover bridge and has a single HoverCloseDelay, and 100ms is the more
|
|
// forgiving of the two, which is the whole point of the grace period.
|
|
// - The trigger element is built here instead of with Floating.Trigger. A tooltip
|
|
// wraps arbitrary content — usually a <button> — so the trigger can be neither a
|
|
// <button> itself (nested buttons are invalid) nor carry Floating.Trigger's
|
|
// Enter/Space keydown toggle: that handler sits on the wrapper, DOM keydown
|
|
// bubbles up from the wrapped button, and its preventDefault would swallow the
|
|
// button's own Enter activation. The hover/close TIMERS still come from Floating
|
|
// (hoverEnter / hoverLeave), and the panel keeps Floating's half of the bridge —
|
|
// nothing about the bridge is reimplemented here.
|
|
// - Tooltips are Standalone: they never join the single-open manager. In the TSX a
|
|
// tooltip evicted whatever popover or menu was open — including the one its own
|
|
// trigger lived inside, which yanked the trigger out from under the cursor.
|
|
package webui
|
|
|
|
import "kjol/vdom"
|
|
|
|
const tooltipCls = "bg-neutral-800 text-white text-sm px-2.5 py-1.5 rounded-default shadow-lg max-w-80 relative"
|
|
|
|
// Defaults from the TSX: TOOLTIP_DEFAULT_OFFSET = 8, delay ?? 200, close after 100ms.
|
|
const (
|
|
tooltipOffset = 8
|
|
tooltipOpenDelay = 200
|
|
tooltipCloseDelay = 100
|
|
// The rotated square is ~11px across the diagonal and pokes ~6px out of the
|
|
// panel — the same silhouette as the TSX's 6px border triangle.
|
|
tooltipArrowSize = 8
|
|
)
|
|
|
|
// Tooltip trigger modes (TooltipProps.Trigger).
|
|
const (
|
|
TooltipTriggerHover = "hover"
|
|
TooltipTriggerFocus = "focus"
|
|
)
|
|
|
|
// TooltipProps configures NewTooltip.
|
|
type TooltipProps struct {
|
|
// Trigger is TooltipTriggerHover (default) or TooltipTriggerFocus. A focus
|
|
// tooltip does not open on hover; it opens when anything inside it takes focus.
|
|
// Both are keyboard-reachable — focusin/focusout bubble out of the wrapped
|
|
// element, so no tabindex is needed on the wrapper.
|
|
Trigger string
|
|
|
|
// Placement defaults to "top". Any Placement* constant works; the resolved
|
|
// placement may differ after a flip (the arrow follows it).
|
|
Placement string
|
|
|
|
// Offset is the gap between trigger and bubble in px; 0 means the TSX default, 8.
|
|
Offset float64
|
|
|
|
// Delay is the hover open delay in ms; 0 means the TSX default, 200. Pass a
|
|
// negative value for no delay (Go cannot tell an unset 0 from a deliberate one).
|
|
Delay int
|
|
|
|
// Class goes on the trigger wrapper, not the bubble.
|
|
Class string
|
|
}
|
|
|
|
// Tooltip is a live tooltip: a Floating plus the trigger wrapper around it.
|
|
//
|
|
// It is a CONTROLLER, not a render function, because a Floating holds refs, timers
|
|
// and open state that must survive across renders. Build it once, next to your
|
|
// signals, and call Render inside the render closure:
|
|
//
|
|
// tip := webui.NewHoverTooltip(webui.PlacementTop, "")
|
|
// return func() *vdom.VNode {
|
|
// return tip.Render(vdom.Text("Deletes the row"), webui.Button(...))
|
|
// }
|
|
//
|
|
// (This is the API change from the old free function `Tooltip(props, children...)`,
|
|
// which could not hold state and so could only ever be CSS.)
|
|
type Tooltip struct {
|
|
f *Floating
|
|
trigger string
|
|
class string
|
|
}
|
|
|
|
// NewTooltip creates a tooltip controller. Call it once, OUTSIDE the render
|
|
// function.
|
|
func NewTooltip(p TooltipProps) *Tooltip {
|
|
delay := p.Delay
|
|
switch {
|
|
case delay == 0:
|
|
delay = tooltipOpenDelay
|
|
case delay < 0:
|
|
delay = 0
|
|
}
|
|
offset := p.Offset
|
|
if offset == 0 {
|
|
offset = tooltipOffset
|
|
}
|
|
|
|
trigger := pick(p.Trigger, TooltipTriggerHover)
|
|
|
|
return &Tooltip{
|
|
trigger: trigger,
|
|
class: p.Class,
|
|
f: NewFloating(FloatingOptions{
|
|
Placement: pick(p.Placement, PlacementTop),
|
|
Offset: offset,
|
|
ArrowSize: tooltipArrowSize,
|
|
ArrowPadding: 6,
|
|
// OpenOnHover is set for BOTH modes: it is what gives the PANEL its half
|
|
// of the hover bridge (enter cancels the pending close, leave reschedules
|
|
// it), which is what lets the cursor land on the bubble and select text in
|
|
// it. The trigger's half is wired in Render — a focus tooltip wires only
|
|
// focusin/focusout there, so it still does not open on hover.
|
|
OpenOnHover: true,
|
|
HoverDelay: delay,
|
|
HoverCloseDelay: tooltipCloseDelay,
|
|
// A tooltip is not a dialog: it must not evict the menu or popover it is
|
|
// hovered inside (which would take its own trigger with it).
|
|
Standalone: true,
|
|
}),
|
|
}
|
|
}
|
|
|
|
// NewHoverTooltip is NewTooltip in hover mode (the TSX's default variant).
|
|
// Placement defaults to "top"; class goes on the trigger wrapper.
|
|
func NewHoverTooltip(placement, class string) *Tooltip {
|
|
return NewTooltip(TooltipProps{Trigger: TooltipTriggerHover, Placement: placement, Class: class})
|
|
}
|
|
|
|
// NewFocusTooltip is NewTooltip in focus mode: it opens when the wrapped content
|
|
// takes focus, not on hover.
|
|
func NewFocusTooltip(placement, class string) *Tooltip {
|
|
return NewTooltip(TooltipProps{Trigger: TooltipTriggerFocus, Placement: placement, Class: class})
|
|
}
|
|
|
|
// Floating exposes the underlying controller (placement, open state, Dispose...).
|
|
func (t *Tooltip) Floating() *Floating { return t.f }
|
|
|
|
// IsOpen reports whether the bubble is showing.
|
|
func (t *Tooltip) IsOpen() bool { return t.f.IsOpen() }
|
|
|
|
// Hide closes the bubble now — e.g. when the thing it describes is removed.
|
|
func (t *Tooltip) Hide() { t.f.Hide() }
|
|
|
|
// Dispose closes the bubble and drops every listener and timer it owns.
|
|
func (t *Tooltip) Dispose() { t.f.Dispose() }
|
|
|
|
// Render draws the trigger wrapper around children, plus the (portaled) bubble
|
|
// holding content.
|
|
//
|
|
// content is passed per render rather than stored on the props so it can depend on
|
|
// signals — and so the same *VNode is never handed to the reconciler twice.
|
|
//
|
|
// Call this on EVERY render, in the same slot: when the tooltip is closed the panel
|
|
// is an empty portal, not a missing child, which is what keeps the sibling indexes
|
|
// the reconciler diffs by stable.
|
|
func (t *Tooltip) Render(content *vdom.VNode, children ...*vdom.VNode) *vdom.VNode {
|
|
// The TSX wraps a hover trigger in `<span style="display:inline-block">` and a
|
|
// focus trigger in a plain `<div>`. Same here — the wrapper is what gets measured,
|
|
// so it has to shrink-wrap the trigger rather than stretch across the line box.
|
|
tag, class := "span", cx("inline-block", t.class)
|
|
if t.trigger == TooltipTriggerFocus {
|
|
tag, class = "div", t.class
|
|
}
|
|
|
|
mods := []vdom.Mod{
|
|
vdom.WithRef(t.f.triggerRef),
|
|
vdom.Attr("class", class),
|
|
}
|
|
if t.trigger == TooltipTriggerHover {
|
|
// The controller's timers, not ours: hoverEnter opens after Delay, hoverLeave
|
|
// schedules the close that the panel's own mouseenter can cancel.
|
|
mods = append(mods,
|
|
vdom.On(vdom.EVENT_MOUSEENTER, t.f.hoverEnter),
|
|
vdom.On(vdom.EVENT_MOUSELEAVE, t.f.hoverLeave),
|
|
)
|
|
}
|
|
// focusin/focusout BUBBLE (focus/blur do not), so focusing the wrapped button or
|
|
// input opens the tooltip without the wrapper needing a tabindex of its own —
|
|
// which is how the keyboard reaches it now that the old port's unconditional
|
|
// tabindex="0" (and the tab-order pollution it caused) is gone.
|
|
mods = append(mods,
|
|
vdom.On(vdom.EVENT_FOCUSIN, t.f.Show),
|
|
// Not Hide: leaving on the grace timer means tabbing away, or blurring to grab
|
|
// the bubble with the mouse, does not snatch the text out from under you.
|
|
vdom.On(vdom.EVENT_FOCUSOUT, t.f.hoverLeave),
|
|
)
|
|
|
|
mods = kids(mods, children)
|
|
mods = append(mods, t.panel(content))
|
|
return vdom.El(tag, mods...)
|
|
}
|
|
|
|
// panel is the bubble: role=tooltip, portaled, with the arrow last so it paints over
|
|
// the bubble's own background.
|
|
func (t *Tooltip) panel(content *vdom.VNode) *vdom.VNode {
|
|
return t.f.Panel(
|
|
FloatingPanelProps{Role: "tooltip", Class: tooltipCls},
|
|
content,
|
|
// The arrow's side comes from the RESOLVED placement (Floating.Arrow reads
|
|
// it), so a tooltip that flipped from top to bottom points UP at its trigger
|
|
// instead of down at nothing — the bug in the old tooltipArrowCls, which was
|
|
// handed the requested placement.
|
|
t.f.Arrow("bg-neutral-800"),
|
|
)
|
|
}
|