Add fonts, autotable, autotable examples
This commit is contained in:
524
go/webui/menu.go
524
go/webui/menu.go
@@ -2,24 +2,60 @@ package webui
|
||||
|
||||
import "kjol/vdom"
|
||||
|
||||
// Port of web/kit/Menu.tsx.
|
||||
// Port of web/uikit/Menu.tsx, rebuilt on the Floating controller (floating.go).
|
||||
//
|
||||
// NOTE: floating-ui positioning (FloatingRoot / FloatingTrigger / FloatingContent
|
||||
// / useFloatingContext / useFloatingHover) is dropped. Menu is a `relative`
|
||||
// wrapper and MenuContent is an `absolute` dropdown positioned with static
|
||||
// Tailwind utilities chosen from MenuPlacement; there is no viewport-aware
|
||||
// collision detection.
|
||||
// NOTE: Solid's MenuContext (closeMenu / openOnHover / cancelParentClose) is
|
||||
// dropped. Open state is a passed bool: MenuContent and Submenu render only when
|
||||
// open, and the caller toggles it via MenuTrigger's onToggle. Item clicks no
|
||||
// longer auto-close the menu (closeOnClick / closeMenu removed) — the caller
|
||||
// closes it from its own click handler.
|
||||
// NOTE: hover-open/hover-close timers, the MenuTrigger render-prop (isOpen state)
|
||||
// and asChild, Submenu's getBoundingClientRect positioning with scroll/resize
|
||||
// listeners, and MenuItem's Enter/Space keydown handler (the runtime's Event
|
||||
// exposes no key) are all dropped.
|
||||
// NOTE: the imported "Placement" type is renamed MenuPlacement to avoid colliding
|
||||
// with a future Floating port.
|
||||
// # 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"
|
||||
@@ -27,104 +63,292 @@ 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"
|
||||
|
||||
// MenuPlacement selects where MenuContent is positioned relative to its trigger.
|
||||
type MenuPlacement string
|
||||
// defaultHoverCloseDelay is the TSX's 150ms grace period after the cursor leaves.
|
||||
const defaultHoverCloseDelay = 150
|
||||
|
||||
const (
|
||||
MenuPlacementBottomStart MenuPlacement = "bottom-start"
|
||||
MenuPlacementBottomEnd MenuPlacement = "bottom-end"
|
||||
MenuPlacementTopStart MenuPlacement = "top-start"
|
||||
MenuPlacementTopEnd MenuPlacement = "top-end"
|
||||
MenuPlacementLeftStart MenuPlacement = "left-start"
|
||||
MenuPlacementRightStart MenuPlacement = "right-start"
|
||||
)
|
||||
// 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
|
||||
|
||||
// menuPlacementClasses maps a placement to the static absolute-position utilities
|
||||
// (the ~4px offset becomes the mt-1/mb-1/ml-1/mr-1 margin).
|
||||
var menuPlacementClasses = map[MenuPlacement]string{
|
||||
MenuPlacementBottomStart: "top-full left-0 mt-1",
|
||||
MenuPlacementBottomEnd: "top-full right-0 mt-1",
|
||||
MenuPlacementTopStart: "bottom-full left-0 mb-1",
|
||||
MenuPlacementTopEnd: "bottom-full right-0 mb-1",
|
||||
MenuPlacementLeftStart: "right-full top-0 mr-1",
|
||||
MenuPlacementRightStart: "left-full top-0 ml-1",
|
||||
// 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 positioning context: a relative wrapper holding a MenuTrigger and a
|
||||
// MenuContent.
|
||||
func Menu(class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
return vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", cx("relative inline-block", class))}, children)...)
|
||||
// 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
|
||||
}
|
||||
|
||||
// MenuTrigger wraps the clickable element that toggles the menu open/closed.
|
||||
func MenuTrigger(onToggle func(), class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("class", class),
|
||||
vdom.Attr("aria-haspopup", "menu"),
|
||||
// NewMenu creates a menu controller. Call it once, alongside your signals.
|
||||
func NewMenu(o MenuOptions) *Menu {
|
||||
if o.HoverCloseDelay <= 0 {
|
||||
o.HoverCloseDelay = defaultHoverCloseDelay
|
||||
}
|
||||
if onToggle != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onToggle))
|
||||
}
|
||||
return vdom.El("div", kids(mods, children)...)
|
||||
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
|
||||
}
|
||||
|
||||
// MenuContent is the dropdown panel; it renders only when open, positioned with
|
||||
// static Tailwind utilities for the given placement.
|
||||
func MenuContent(open bool, placement MenuPlacement, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
if !open {
|
||||
return nil
|
||||
}
|
||||
pos := menuPlacementClasses[placement]
|
||||
if pos == "" {
|
||||
pos = menuPlacementClasses[MenuPlacementBottomStart]
|
||||
}
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("class", cx("absolute z-50", pos, menuCls, class)),
|
||||
vdom.Attr("role", "menu"),
|
||||
}
|
||||
return vdom.El("div", kids(mods, children)...)
|
||||
// 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
|
||||
}
|
||||
|
||||
// MenuItemProps configures MenuItem.
|
||||
// ---- 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
|
||||
}
|
||||
|
||||
// MenuItem is a <button> menu entry.
|
||||
func MenuItem(p MenuItemProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
// 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"))
|
||||
} else if p.OnClick != nil {
|
||||
mods = append(mods, vdom.On(vdom.EVENT_CLICK, p.OnClick))
|
||||
}
|
||||
if p.Icon != "" {
|
||||
mods = append(mods, Icon(p.Icon, 16, "shrink-0"))
|
||||
}
|
||||
return vdom.El("button", kids(mods, children)...)
|
||||
return vdom.Button(kids(mods, children)...)
|
||||
}
|
||||
|
||||
// MenuLink is an anchor menu entry for internal navigation.
|
||||
func MenuLink(href, icon, class string, children ...*vdom.VNode) *vdom.VNode {
|
||||
// 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.El("a", kids(mods, children)...)
|
||||
return vdom.A(kids(mods, children)...)
|
||||
}
|
||||
|
||||
// MenuAnchorProps configures MenuAnchor.
|
||||
// MenuAnchorProps configures Menu.Anchor.
|
||||
type MenuAnchorProps struct {
|
||||
Href string
|
||||
Icon string
|
||||
@@ -133,17 +357,18 @@ type MenuAnchorProps struct {
|
||||
Class string
|
||||
}
|
||||
|
||||
// MenuAnchor is an anchor menu entry to an external URL (Target defaults to
|
||||
// _blank, Rel to noopener noreferrer). A _blank target appends a trailing arrow.
|
||||
func MenuAnchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
// 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")
|
||||
rel := pick(p.Rel, "noopener noreferrer")
|
||||
mods := []vdom.Mod{
|
||||
vdom.Attr("href", p.Href),
|
||||
vdom.Attr("target", target),
|
||||
vdom.Attr("rel", rel),
|
||||
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"))
|
||||
@@ -152,70 +377,109 @@ func MenuAnchor(p MenuAnchorProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
if target == "_blank" {
|
||||
mods = append(mods, Icon("arrow-right", 12, "shrink-0 ml-auto text-neutral-400"))
|
||||
}
|
||||
return vdom.El("a", mods...)
|
||||
return vdom.A(mods...)
|
||||
}
|
||||
|
||||
// MenuDivider is a horizontal separator between groups of items.
|
||||
func MenuDivider(class string) *vdom.VNode {
|
||||
return vdom.El("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.El("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.El("div", kids(mods, children)...)
|
||||
}
|
||||
|
||||
// SubmenuProps configures Submenu.
|
||||
// SubmenuProps configures the submenu's trigger row.
|
||||
type SubmenuProps struct {
|
||||
Open bool
|
||||
OnToggle func()
|
||||
Trigger string
|
||||
Icon string
|
||||
Class string
|
||||
Trigger string
|
||||
Icon string
|
||||
Class string
|
||||
}
|
||||
|
||||
// Submenu is a nested menu opened from a parent item. When Open, its panel
|
||||
// renders to the right of the trigger via static Tailwind (no measurement).
|
||||
func Submenu(p SubmenuProps, children ...*vdom.VNode) *vdom.VNode {
|
||||
ariaExpanded := "false"
|
||||
if p.Open {
|
||||
ariaExpanded = "true"
|
||||
}
|
||||
|
||||
// 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))
|
||||
|
||||
triggerMods := []vdom.Mod{
|
||||
vdom.Attr("role", "menuitem"),
|
||||
vdom.Attr("aria-haspopup", "menu"),
|
||||
vdom.Attr("aria-expanded", ariaExpanded),
|
||||
vdom.Attr("class", cx(menuSubmenuTriggerCls, p.Class)),
|
||||
}
|
||||
if p.OnToggle != nil {
|
||||
triggerMods = append(triggerMods, vdom.On(vdom.EVENT_CLICK, p.OnToggle))
|
||||
}
|
||||
triggerMods = append(triggerMods,
|
||||
vdom.El("span", label...),
|
||||
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)
|
||||
|
||||
wrap := []vdom.Mod{vdom.Attr("class", "relative"), vdom.El("div", triggerMods...)}
|
||||
if p.Open {
|
||||
contentMods := []vdom.Mod{
|
||||
vdom.Attr("role", "menu"),
|
||||
vdom.Attr("class", cx("absolute left-full top-0 ml-1 z-[51]", menuCls)),
|
||||
}
|
||||
wrap = append(wrap, vdom.El("div", kids(contentMods, children)...))
|
||||
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.El("div", wrap...)
|
||||
|
||||
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]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user