486 lines
19 KiB
Go
486 lines
19 KiB
Go
package webui
|
|
|
|
import "kjol/vdom"
|
|
|
|
// Port of web/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-white rounded-default shadow-lg border border-neutral-200 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-neutral-700 bg-transparent border-0 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900 focus:bg-neutral-100 focus:outline-hidden disabled:opacity-50 disabled:cursor-not-allowed"
|
|
const menuDividerCls = "my-1 -mx-1.5 border-0 border-t border-neutral-200"
|
|
const menuSectionCls = "pt-1.5 pb-0.5 px-2 text-[10px] font-semibold text-neutral-400 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-neutral-700 cursor-pointer hover:bg-neutral-100 hover:text-neutral-900"
|
|
|
|
// 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-neutral-400"))
|
|
}
|
|
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-neutral-400"),
|
|
)
|
|
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]
|
|
}
|