initial port of the UI kit
This commit is contained in:
221
go/webui/menu.go
Normal file
221
go/webui/menu.go
Normal file
@@ -0,0 +1,221 @@
|
||||
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...)
|
||||
}
|
||||
Reference in New Issue
Block a user