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