Files
kjol/go/webui/tabs.go

148 lines
4.3 KiB
Go

package webui
import (
"strconv"
"strings"
"kjol/vdom"
)
// Port of web/kit/Tabs.tsx.
//
// NOTE: The TSX persisted the active index in localStorage (storageKey) and
// synced sibling tab groups via synthetic "storage" events on mount. Those are
// browser-only side effects with no neutral-runtime equivalent, so they are
// dropped: selection collapses to a plain ActiveIndex value plus an OnTabChange
// callback (the caller owns the state, per the porting guide). The TSX
// defaultIndex prop likewise collapses into the caller-supplied ActiveIndex.
const tabsBase = "flex items-center gap-1.5 cursor-pointer py-2 px-4 text-sm font-medium bg-transparent border-0 border-b-2 transition-[color,border-color] duration-150"
const tabsInactive = "text-text-muted border-neutral-200 hover:text-text-body"
const tabsActive = "text-primary border-primary"
// TabItem is one tab: a title, an optional numeric badge (shown only when > 0),
// and optional inline panel content.
type TabItem struct {
Title string
Badge int
Content *vdom.VNode
}
// TabGroupProps configures TabGroup. ActiveIndex is the selected tab; clicking a
// tab calls OnTabChange with its index (the caller updates ActiveIndex).
type TabGroupProps struct {
Items []TabItem
ActiveIndex int
OnTabChange func(int)
// Actions is optional content rendered on the right side of the tab bar.
Actions *vdom.VNode
// Class adds extra classes on the root (e.g. "ui-tabs" for structured panel
// CSS, "page-tabs" for tighter spacing).
Class string
// Fill makes tabs fill available height with internally-scrolling panels.
Fill bool
// Stretch is tri-state (nil = unset): when Actions is nil it defaults to
// true, otherwise it defaults to false — matching the TSX prop semantics.
Stretch *bool
PageTabs bool
}
// TabGroup renders a horizontal tab bar with optional inline panels.
func TabGroup(p TabGroupProps) *vdom.VNode {
structured := p.Fill || strings.Contains(p.Class, "ui-tabs")
pageTabs := p.PageTabs || strings.Contains(p.Class, "page-tabs")
var rootCls string
switch {
case p.Fill:
rootCls = cx("ui-tabs flex w-full min-h-0 flex-1 flex-col overflow-hidden", p.Class)
case pageTabs:
rootCls = cx("w-full page-tabs", p.Class)
default:
rootCls = cx("w-full pb-4", p.Class)
}
headerCls := "overflow-x-auto flex flex-row w-full text-sm"
if structured {
headerCls = "header overflow-x-auto flex flex-row w-full shrink-0 text-sm"
}
stretchTabs := false
if p.Actions != nil {
stretchTabs = p.Stretch != nil && *p.Stretch
} else {
stretchTabs = p.Stretch == nil || *p.Stretch
}
stretchCls := ""
if stretchTabs {
stretchCls = "flex-1 justify-center md:flex-initial md:justify-start"
}
panelCls := func(index int) string {
active := index == p.ActiveIndex
if !structured {
if active {
return ""
}
return "hidden"
}
if !active {
return "panel hidden"
}
if p.Fill {
return "panel flex min-h-0 flex-1 flex-col overflow-hidden"
}
return "panel"
}
header := []vdom.Mod{vdom.Attr("class", headerCls)}
for i, item := range p.Items {
idx := i
state := tabsInactive
if i == p.ActiveIndex {
state = tabsActive
}
btn := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", cx(tabsBase, stretchCls, state)),
vdom.On(vdom.EVENT_CLICK, func() {
if p.OnTabChange != nil {
p.OnTabChange(idx)
}
}),
vdom.Text(item.Title),
}
if item.Badge > 0 {
btn = append(btn, vdom.El("span",
vdom.Attr("class", "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"),
vdom.Text(strconv.Itoa(item.Badge))))
}
header = append(header, vdom.El("button", btn...))
}
if p.Actions != nil {
header = append(header, vdom.El("div",
vdom.Attr("class", "tab-actions flex-1 self-end border-b-2 border-neutral-200 flex items-center justify-end pb-1"),
vdom.El("div", vdom.Attr("class", "flex items-center min-w-0"), p.Actions),
))
}
root := []vdom.Mod{vdom.Attr("class", rootCls), vdom.El("div", header...)}
hasInlinePanels := false
for _, item := range p.Items {
if item.Content != nil {
hasInlinePanels = true
break
}
}
if hasInlinePanels {
for i, item := range p.Items {
if item.Content == nil {
continue
}
root = append(root, vdom.El("div", vdom.Attr("class", panelCls(i)), item.Content))
}
}
return vdom.El("div", root...)
}