222 lines
8.1 KiB
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...)
|
|
}
|