Files
kjol/go/webui/menu.go

486 lines
19 KiB
Go

package webui
import "kjol/wasmruntime/vdom"
// Port of jsruntime/uikit/Menu.tsx, rebuilt on the Floating controller (floating.go).
//
// # Why the API is a controller with methods
//
// The TSX threads three things through a Solid MenuContext: closeMenu (so an item
// click dismisses the menu it lives in), the hover settings, and cancelParentClose
// (so moving the cursor onto a submenu does not close the menu that spawned it).
// Go has no context — so the menu IS the object. *Menu is a controller you create
// once, next to your signals and NEVER inside a render function (it owns a
// Floating, which owns DOM refs; rebuilding it every frame would throw those away
// each render). Every part of the menu is a method on it, and the receiver is the
// answer to "which menu does this close":
//
// menu := webui.NewMenu(webui.MenuOptions{Placement: webui.PlacementBottomEnd})
// sub := webui.NewSubmenu(menu) // a submenu is owned by its parent menu
//
// return func() *vdom.VNode {
// return vdom.Div(
// menu.TriggerFunc(webui.MenuTriggerProps{Class: "btn"}, func(open bool) *vdom.VNode {
// return webui.Icon(chevronFor(open), 16, "") // the render-prop: chevron follows open state
// }),
// menu.Content("",
// menu.Item(webui.MenuItemProps{Icon: "user"}, vdom.Text("Profile")),
// MenuDivider(""),
// sub.Submenu(webui.SubmenuProps{Trigger: "Export", Icon: "download"},
// sub.Item(webui.MenuItemProps{OnClick: exportCSV}, vdom.Text("CSV")),
// ),
// ),
// )
// }
//
// Binding the close to the receiver is the whole point. The previous port dropped
// MenuContext, which left items with no way to close their menu: every menu in both
// consuming apps stayed open after you clicked an item. A method cannot forget its
// receiver; a `Menu *Menu` field in a props struct can be — and eventually would be —
// left out, silently reintroducing exactly that bug.
//
// # Signature changes from the previous port (all forced by the above)
//
// - Menu is now a TYPE (the controller), not a wrapper element. There is no
// `relative inline-block` wrapper any more: the panel is portaled to
// document.body and positioned by measurement, so it needs no positioned
// ancestor — and, crucially, can no longer be CLIPPED by one. menuCls carries
// `overflow-y-auto`, so the old in-flow submenu was clipped by its own parent
// menu the moment it was taller or wider than the scroll box.
// - MenuTrigger / MenuContent / MenuItem / MenuLink / MenuAnchor / Submenu are
// methods (Trigger / TriggerFunc / Content / Item / Link / Anchor / Submenu).
// - MenuPlacement and its constants are gone; placement is one of the Placement*
// strings from position.go, which is what Floating speaks.
// - MenuItemProps.KeepOpen replaces the TSX's closeOnClick, inverted so Go's zero
// value gives the TSX default (closeOnClick: true — items close the menu).
//
// MenuDivider, MenuSection and MenuGroup stay free functions: they hold no state
// and have nothing to close.
const menuCls = "bg-surface rounded-default shadow-lg border border-line p-1.5 min-w-48 max-h-96 overflow-y-auto"
const menuItemCls = "flex items-center gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink-soft bg-transparent border-0 cursor-pointer hover:bg-surface-raised hover:text-ink focus:bg-surface-raised focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-line"
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-ink-faint uppercase tracking-wide text-left"
const menuSubmenuTriggerCls = "flex items-center justify-between gap-2 w-full py-1.5 px-2 rounded-default text-sm text-left text-ink-soft cursor-pointer hover:bg-surface-raised hover:text-ink"
// defaultHoverCloseDelay is the TSX's 150ms grace period after the cursor leaves.
const defaultHoverCloseDelay = 150
// MenuOptions configures NewMenu. The zero value is a click-to-open menu, placed
// bottom-start with the standard 4px offset.
type MenuOptions struct {
// Placement is one of the Placement* constants (default PlacementBottomStart).
Placement string
// Offset is the gap between trigger and panel, in px (default 4).
Offset float64
// OpenOnHover turns the trigger into a hover target (it still opens on click).
// HoverDelay is how long the cursor must rest before it opens; HoverCloseDelay
// is the grace period after leaving — the bridge that lets the cursor cross the
// gap onto the panel without it vanishing (default 150ms). Submenus inherit
// HoverCloseDelay, exactly as they inherited it through the TSX's MenuContext.
OpenOnHover bool
HoverDelay int
HoverCloseDelay int
// Standalone opts this menu out of the single-open manager. Set it for a menu
// that lives INSIDE another floating — an insert menu in a popover form, say —
// which would otherwise be read as "a rival panel opened" and close the very
// popover it belongs to. (A submenu gets this automatically; see NewSubmenu.)
Standalone bool
OnOpenChange func(bool)
}
// Menu is the controller for one dropdown menu (or, via NewSubmenu, one nested
// submenu). Create it once — outside the render function — and render its parts
// with Trigger/TriggerFunc, Content, Item, Link, Anchor and Submenu.
type Menu struct {
f *Floating
parent *Menu // nil for a root menu
subs []*Menu // submenus opened from this menu; closed when this menu closes
hoverCloseDelay int
}
// NewMenu creates a menu controller. Call it once, alongside your signals.
func NewMenu(o MenuOptions) *Menu {
if o.HoverCloseDelay <= 0 {
o.HoverCloseDelay = defaultHoverCloseDelay
}
m := &Menu{hoverCloseDelay: o.HoverCloseDelay}
m.f = NewFloating(FloatingOptions{
Placement: pick(o.Placement, PlacementBottomStart),
Offset: o.Offset,
Standalone: o.Standalone,
OpenOnHover: o.OpenOnHover,
HoverDelay: o.HoverDelay,
HoverCloseDelay: o.HoverCloseDelay,
// A long menu near the bottom of the screen scrolls inside its own box
// rather than running off it.
ConstrainToViewport: true,
OnOpenChange: func(open bool) {
// A submenu is Standalone (see NewSubmenu), so the single-open manager
// will not close it for us — its parent owns it and must. This also
// covers the indirect close: another menu opening, an outside click, or
// Escape all route through Hide, and so through here.
if !open {
m.closeSubs()
}
if o.OnOpenChange != nil {
o.OnOpenChange(open)
}
},
})
return m
}
// NewSubmenu creates a submenu owned by parent. Like NewMenu, call it once,
// outside the render function.
//
// The submenu's Floating is deliberately Standalone: the single-open manager would
// otherwise read "submenu opens" as "a rival panel opened" and close the very menu
// the submenu hangs off. Ownership is expressed structurally instead — the parent
// closes its submenus when it closes (see NewMenu's OnOpenChange), and the
// open-order stack still makes Escape close the submenu first and an outside-click
// on the submenu leave the parent alone.
//
// Positioning is right-start with no offset, which is what the TSX hand-rolled
// (Menu.tsx:252-287). It does NOT reimplement that math: ComputePosition flips to
// the left side when the right does not fit — and, unlike the TSX, only when the
// left side actually fits, instead of flipping unconditionally into an equally bad
// spot — clamps vertically, and reports the room available so a tall submenu can
// scroll (ConstrainToViewport). NewFloating turns Offset 0 into its 4px default;
// the resulting sliver of a gap is spanned by the hover bridge.
//
// A submenu always opens on hover (the TSX's Submenu did, whatever the parent's
// openOnHover said) and toggles on click, so it is reachable by touch and keyboard.
func NewSubmenu(parent *Menu) *Menu {
s := &Menu{parent: parent, hoverCloseDelay: parent.hoverCloseDelay}
s.f = NewFloating(FloatingOptions{
Placement: PlacementRightStart,
Offset: 0,
Standalone: true,
ConstrainToViewport: true,
OpenOnHover: true,
HoverCloseDelay: parent.hoverCloseDelay,
OnOpenChange: func(open bool) {
if !open {
s.closeSubs()
}
},
})
parent.subs = append(parent.subs, s)
return s
}
// ---- state ----
// IsOpen reports whether this menu's panel is open. Safe to read during render.
func (m *Menu) IsOpen() bool { return m.f.IsOpen() }
// Open shows the menu, Toggle flips it.
func (m *Menu) Open() { m.f.Show() }
func (m *Menu) Toggle() { m.f.Toggle() }
// Close dismisses this menu AND every menu above it. Clicking an item inside a
// submenu therefore tears down the whole stack, which is what the TSX did by
// handing every level the ROOT's closeMenu (the root's unmount took its nested
// submenus with it). Descendants come down too, via NewMenu's OnOpenChange.
func (m *Menu) Close() {
for cur := m; cur != nil; cur = cur.parent {
cur.f.Hide()
}
}
// Dispose closes this menu and its submenus and drops every listener. Call it if
// the component owning the menu goes away while it might still be open.
func (m *Menu) Dispose() {
for _, s := range m.subs {
s.Dispose()
}
m.f.Dispose()
}
func (m *Menu) closeSubs() {
for _, s := range m.subs {
s.f.Hide() // cascades: each sub's own OnOpenChange closes ITS subs
}
}
// ---- the parent<->child hover chain ----
//
// Floating's hover bridge spans a trigger and ITS OWN panel. It cannot span a
// parent menu and a child submenu, because the submenu's panel is portaled to
// document.body: visually the submenu sits flush against the menu that spawned it,
// but in the DOM it is nowhere near it, so crossing from one to the other fires the
// parent's mouseleave and starts its close timer. (In the TSX the submenu panel was
// an in-flow child of the parent panel, so DOM containment gave this away for free —
// at the cost of being clipped by the parent's overflow-y-auto, which is the bug we
// are fixing.) These two restore the containment the DOM no longer expresses: this
// is the TSX's cancelParentClose, generalized up the whole chain.
// cancelAncestorClose stops every ancestor's pending hover-close: the cursor is on
// this submenu, which counts as being on all of them.
func (m *Menu) cancelAncestorClose() {
for cur := m.parent; cur != nil; cur = cur.parent {
cur.f.cancelHover()
}
}
// scheduleAncestorClose re-arms them when the cursor leaves this submenu's panel —
// leaving a submenu means leaving everything it hangs off. Only hover-managed
// ancestors are re-armed; a click-opened root menu stays open until it is clicked
// away, exactly as in the TSX (where MenuContent only wired hover handlers when
// openOnHover was set).
func (m *Menu) scheduleAncestorClose() {
for cur := m.parent; cur != nil; cur = cur.parent {
if cur.f.opts.OpenOnHover {
cur.f.hoverLeave()
}
}
}
// ---- rendering ----
// MenuTriggerProps configures the element that opens the menu.
type MenuTriggerProps struct {
Class string
Title string
// Tag is the trigger element; default "button" (what the TSX rendered). Use
// "div" or "span" when the trigger's own content is a <button> — nesting
// buttons is invalid HTML and the inner one swallows the click.
Tag string
}
// Trigger renders the element that toggles the menu. It also opens on hover when
// MenuOptions.OpenOnHover is set, and closes on Escape.
func (m *Menu) Trigger(p MenuTriggerProps, children ...*vdom.VNode) *vdom.VNode {
return m.f.Trigger(FloatingTriggerProps{Class: p.Class, Title: p.Title, Tag: p.Tag}, children...)
}
// TriggerFunc is Trigger with the TSX's render-prop children — `(state: {isOpen}) => JSX`,
// which callers use to flip a chevron with the menu's state. Go has no JSX callback
// convention, so the open state is passed as a plain bool:
//
// menu.TriggerFunc(webui.MenuTriggerProps{}, func(open bool) *vdom.VNode {
// if open { return webui.Icon("chevron-up", 16, "") }
// return webui.Icon("chevron-down", 16, "")
// })
func (m *Menu) TriggerFunc(p MenuTriggerProps, render func(open bool) *vdom.VNode) *vdom.VNode {
if render == nil {
return m.Trigger(p)
}
return m.Trigger(p, render(m.f.IsOpen()))
}
// Content renders the dropdown panel: portaled to document.body, measured, and
// revealed only once it has been positioned. It renders nothing while closed.
func (m *Menu) Content(class string, children ...*vdom.VNode) *vdom.VNode {
return m.f.Panel(FloatingPanelProps{Class: cx(menuCls, class)}, children...)
}
// MenuItemProps configures Menu.Item.
type MenuItemProps struct {
Icon string
Disabled bool
OnClick func()
// KeepOpen leaves the menu open after the click. This is the TSX's closeOnClick,
// inverted: closeOnClick defaulted to TRUE, and Go props have no "unset", so the
// negative form is what preserves the default in the zero value.
KeepOpen bool
Class string
}
// Item is a <button> menu entry. It closes the menu (and, from inside a submenu,
// the whole stack) after running OnClick, unless KeepOpen is set.
func (m *Menu) Item(p MenuItemProps, children ...*vdom.VNode) *vdom.VNode {
activate := func() {
if p.Disabled {
return
}
if p.OnClick != nil {
p.OnClick()
}
if !p.KeepOpen {
m.Close()
}
}
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("role", "menuitem"),
vdom.Attr("class", cx(menuItemCls, p.Class)),
vdom.On(vdom.EVENT_CLICK, activate),
// A <button> already synthesizes a click from Enter/Space, but the TSX handled
// the keys explicitly and called preventDefault — which also stops Space from
// scrolling the page behind the menu. Ported as-is; preventDefault suppresses
// the synthesized click, so the item fires exactly once.
vdom.OnEvent(vdom.EVENT_KEYDOWN, func(ev vdom.Event) {
switch ev.Key() {
case vdom.KEY_ENTER, vdom.KEY_SPACE:
ev.PreventDefault()
activate()
}
}),
}
if p.Disabled {
mods = append(mods, vdom.Attr("disabled", "disabled"))
}
if p.Icon != "" {
mods = append(mods, Icon(p.Icon, 16, "shrink-0"))
}
return vdom.Button(kids(mods, children)...)
}
// Link is an anchor menu entry for internal navigation. It closes the menu on
// click, like the TSX's MenuLink.
func (m *Menu) Link(href, icon, class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("href", href),
vdom.Attr("role", "menuitem"),
vdom.Attr("class", cx(menuItemCls, class)),
vdom.On(vdom.EVENT_CLICK, m.Close),
}
if icon != "" {
mods = append(mods, Icon(icon, 16, "shrink-0"))
}
return vdom.A(kids(mods, children)...)
}
// MenuAnchorProps configures Menu.Anchor.
type MenuAnchorProps struct {
Href string
Icon string
Target string
Rel string
Class string
}
// Anchor is an anchor menu entry to an external URL (Target defaults to _blank, Rel
// to noopener noreferrer). A _blank target appends a trailing arrow. It closes the
// menu on click.
func (m *Menu) Anchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
target := pick(p.Target, "_blank")
mods := []vdom.Mod{
vdom.Attr("href", p.Href),
vdom.Attr("target", target),
vdom.Attr("rel", pick(p.Rel, "noopener noreferrer")),
vdom.Attr("role", "menuitem"),
vdom.Attr("class", cx(menuItemCls, p.Class)),
vdom.On(vdom.EVENT_CLICK, m.Close),
}
if p.Icon != "" {
mods = append(mods, Icon(p.Icon, 16, "shrink-0"))
}
mods = kids(mods, children)
if target == "_blank" {
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-ink-faint"))
}
return vdom.A(mods...)
}
// SubmenuProps configures the submenu's trigger row.
type SubmenuProps struct {
Trigger string
Icon string
Class string
}
// Submenu renders a submenu created with NewSubmenu: the trigger row that sits
// among the parent's items, plus the submenu's own portaled panel. Call it on the
// SUBMENU controller and give it the submenu's items as children, so those items
// close through the submenu (and so up the whole chain):
//
// sub.Submenu(webui.SubmenuProps{Trigger: "Export", Icon: "download"},
// sub.Item(webui.MenuItemProps{OnClick: exportCSV}, vdom.Text("CSV")),
// )
//
// The two halves are wrapped in a bare <div> only because a Go component returns a
// single VNode; the wrapper is layout-neutral (the panel is portaled out of it and
// the trigger row is w-full either way).
func (m *Menu) Submenu(p SubmenuProps, children ...*vdom.VNode) *vdom.VNode {
label := []vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}
if p.Icon != "" {
label = append(label, Icon(p.Icon, 16, "shrink-0"))
}
label = append(label, vdom.Text(p.Trigger))
trigger := m.f.Trigger(FloatingTriggerProps{
Tag: "div", // a row inside a menu, not a nested <button>
Class: cx(menuSubmenuTriggerCls, p.Class),
// Hover already opened it; the click is for touch and for closing it again.
OnClick: m.f.Toggle,
},
vdom.Span(label...),
Icon("chevron-right", 16, "shrink-0 ml-auto text-ink-faint"),
)
menuSetAttr(trigger, "role", "menuitem")
// Entering the trigger means entering the submenu; it must not let an ancestor
// close underneath it. Its mouseLEAVE deliberately does NOT re-arm the ancestors:
// the cursor is still inside the parent's panel (it just moved to another item),
// and the parent's panel gets no fresh mouseenter to cancel a close we scheduled.
menuChain(trigger, vdom.EVENT_MOUSEENTER, m.cancelAncestorClose)
panel := m.f.Panel(FloatingPanelProps{Class: menuCls}, children...)
if el := menuPanelEl(panel); el != nil {
menuChain(el, vdom.EVENT_MOUSEENTER, m.cancelAncestorClose)
menuChain(el, vdom.EVENT_MOUSELEAVE, m.scheduleAncestorClose)
}
return vdom.Div(trigger, panel)
}
// MenuDivider is a horizontal separator between groups of items.
func MenuDivider(class string) *vdom.VNode {
return vdom.Hr(vdom.Attr("class", cx(menuDividerCls, class)), vdom.Attr("role", "separator"))
}
// MenuSection is an uppercase section label.
func MenuSection(class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", cx(menuSectionCls, class)), vdom.Attr("role", "presentation")}
return vdom.Div(kids(mods, children)...)
}
// MenuGroup groups related items together (role=group).
func MenuGroup(class string, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("role", "group"), vdom.Attr("class", class)}
return vdom.Div(kids(mods, children)...)
}
// ---- small helpers over the nodes Floating builds ----
// menuChain adds fn to a node's handler for event instead of replacing it: the
// submenu's parent-chaining behavior has to run ON TOP of the hover bridge Floating
// already wired into its trigger and panel.
func menuChain(n *vdom.VNode, event string, fn func()) {
if n == nil {
return
}
prev := n.Events[event]
n.Events[event] = func(ev vdom.Event) {
if prev != nil {
prev(ev)
}
fn()
}
}
// menuSetAttr sets an attribute on an already-built node (FloatingTriggerProps has
// no Role field, and a submenu's trigger row is a menuitem).
func menuSetAttr(n *vdom.VNode, k, v string) {
if n != nil {
n.Attrs[k] = v
}
}
// menuPanelEl digs the panel element out of what Floating.Panel returns — a Portal
// wrapping exactly one div — and is nil while the panel is closed and the portal is
// empty.
func menuPanelEl(portal *vdom.VNode) *vdom.VNode {
if portal == nil || len(portal.Children) != 1 {
return nil
}
return portal.Children[0]
}