Files
kjol/go/webui/menu.go

222 lines
8.1 KiB
Go

package webui
import "kjol/vdom"
// Port of web/kit/Menu.tsx.
//
// 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.
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"
// MenuPlacement selects where MenuContent is positioned relative to its trigger.
type MenuPlacement string
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"
)
// 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",
}
// 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)...)
}
// 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"),
}
if onToggle != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onToggle))
}
return vdom.El("div", kids(mods, children)...)
}
// 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)...)
}
// MenuItemProps configures MenuItem.
type MenuItemProps struct {
Icon string
Disabled bool
OnClick func()
Class string
}
// MenuItem is a <button> menu entry.
func MenuItem(p MenuItemProps, children ...*vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("role", "menuitem"),
vdom.Attr("class", cx(menuItemCls, p.Class)),
}
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)...)
}
// MenuLink is an anchor menu entry for internal navigation.
func MenuLink(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)),
}
if icon != "" {
mods = append(mods, Icon(icon, 16, "shrink-0"))
}
return vdom.El("a", kids(mods, children)...)
}
// MenuAnchorProps configures MenuAnchor.
type MenuAnchorProps struct {
Href string
Icon string
Target string
Rel string
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 {
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("role", "menuitem"),
vdom.Attr("class", cx(menuItemCls, p.Class)),
}
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.El("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.
type SubmenuProps struct {
Open bool
OnToggle func()
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"
}
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...),
Icon("chevron-right", 16, "shrink-0 ml-auto text-neutral-400"),
)
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)...))
}
return vdom.El("div", wrap...)
}