Add fonts, autotable, autotable examples

This commit is contained in:
2026-07-13 13:01:26 -04:00
parent ea3d2a6d03
commit cf8342f8d4
71 changed files with 16084 additions and 1443 deletions

View File

@@ -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"),
)
}