Add fonts, autotable, autotable examples
This commit is contained in:
@@ -1,101 +1,222 @@
|
||||
// Port of web/kit/Tooltips.tsx.
|
||||
// Port of web/uikit/Tooltips.tsx, rebuilt on the Floating controller (floating.go).
|
||||
//
|
||||
// NOTE: the TSX builds tooltips on Floating.tsx (floating-ui-style measured
|
||||
// positioning with flip/shift, a Portal, a FloatingContext, and hover/focus
|
||||
// open-delay timers). The neutral runtime has none of that, so this port keeps
|
||||
// the API + Tailwind + arrow markup and instead reveals the bubble purely in CSS
|
||||
// via the Tailwind `group` marker (group-hover / group-focus-within). Placement
|
||||
// is approximated with static absolute utility classes (no flip/shift and no
|
||||
// measured coordinates), the numeric offset collapses to the fixed ~8px m*-2 gap
|
||||
// (the TSX default), and the open delay is dropped.
|
||||
|
||||
// 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"
|
||||
|
||||
// tooltipArrowCls returns the arrow classes for a tooltip on the given base side
|
||||
// (the arrow points back toward the trigger). Ported verbatim from arrowCls.
|
||||
func tooltipArrowCls(base string) string {
|
||||
const common = "absolute w-0 h-0"
|
||||
switch base {
|
||||
case "top":
|
||||
return common + " -bottom-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-t-[6px] border-t-neutral-800"
|
||||
case "bottom":
|
||||
return common + " -top-[6px] left-1/2 -translate-x-1/2 border-l-[6px] border-l-transparent border-r-[6px] border-r-transparent border-b-[6px] border-b-neutral-800"
|
||||
case "left":
|
||||
return common + " -right-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-l-[6px] border-l-neutral-800"
|
||||
case "right":
|
||||
return common + " -left-[6px] top-1/2 -translate-y-1/2 border-t-[6px] border-t-transparent border-b-[6px] border-b-transparent border-r-[6px] border-r-neutral-800"
|
||||
default:
|
||||
return common
|
||||
}
|
||||
}
|
||||
// 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
|
||||
)
|
||||
|
||||
// tooltipPlacementCls positions the bubble relative to the trigger wrapper.
|
||||
func tooltipPlacementCls(placement string) string {
|
||||
switch placement {
|
||||
case "bottom":
|
||||
return "top-full left-1/2 -translate-x-1/2 mt-2"
|
||||
case "left":
|
||||
return "right-full top-1/2 -translate-y-1/2 mr-2"
|
||||
case "right":
|
||||
return "left-full top-1/2 -translate-y-1/2 ml-2"
|
||||
default: // "top"
|
||||
return "bottom-full left-1/2 -translate-x-1/2 mb-2"
|
||||
}
|
||||
}
|
||||
// Tooltip trigger modes (TooltipProps.Trigger).
|
||||
const (
|
||||
TooltipTriggerHover = "hover"
|
||||
TooltipTriggerFocus = "focus"
|
||||
)
|
||||
|
||||
// tooltipRevealCls returns the CSS reveal classes for the given trigger mode.
|
||||
func tooltipRevealCls(trigger string) string {
|
||||
if trigger == "focus" {
|
||||
return "invisible opacity-0 transition-opacity group-focus-within:visible group-focus-within:opacity-100"
|
||||
}
|
||||
return "invisible opacity-0 transition-opacity group-hover:visible group-hover:opacity-100"
|
||||
}
|
||||
|
||||
// TooltipProps configures Tooltip. Trigger is "hover" (default) or "focus";
|
||||
// Placement is "top" (default), "bottom", "left" or "right".
|
||||
// TooltipProps configures NewTooltip.
|
||||
type TooltipProps struct {
|
||||
Content *vdom.VNode
|
||||
Trigger string
|
||||
// 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
|
||||
Class 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 wraps children (the trigger) and shows Content in a floating bubble on
|
||||
// hover (or keyboard focus when Trigger is "focus").
|
||||
func Tooltip(p TooltipProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
placement := pick(p.Placement, "top")
|
||||
// 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
|
||||
}
|
||||
|
||||
bubble := vdom.El("div",
|
||||
vdom.Attr("role", "tooltip"),
|
||||
vdom.Attr("data-floating-content", "true"),
|
||||
vdom.Attr("class", cx("absolute z-[110]", tooltipPlacementCls(placement), tooltipCls, tooltipRevealCls(p.Trigger))),
|
||||
)
|
||||
if p.Content != nil {
|
||||
bubble.Children = append(bubble.Children, p.Content)
|
||||
// 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
|
||||
}
|
||||
bubble.Children = append(bubble.Children, vdom.El("div", vdom.Attr("class", tooltipArrowCls(placement))))
|
||||
|
||||
// The wrapper carries `group` + `tabindex` so both hover and keyboard-focus
|
||||
// reveal work in CSS. inline-block mirrors the TSX trigger's display.
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("class", cx("group relative inline-block", p.Class)),
|
||||
vdom.Attr("tabindex", "0"),
|
||||
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, bubble)
|
||||
return vdom.El("span", mods...)
|
||||
mods = append(mods, t.panel(content))
|
||||
return vdom.El(tag, mods...)
|
||||
}
|
||||
|
||||
// HoverTooltip is Tooltip with hover reveal (the TSX default variant).
|
||||
func HoverTooltip(content *vdom.VNode, placement, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return Tooltip(TooltipProps{Content: content, Trigger: "hover", Placement: placement, Class: class}, children...)
|
||||
}
|
||||
|
||||
// FocusTooltip is Tooltip revealed on keyboard focus.
|
||||
func FocusTooltip(content *vdom.VNode, placement, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return Tooltip(TooltipProps{Content: content, Trigger: "focus", Placement: placement, Class: class}, children...)
|
||||
// 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"),
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user