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