initial port of the UI kit
This commit is contained in:
138
go/webui/crmtabs.go
Normal file
138
go/webui/crmtabs.go
Normal file
@@ -0,0 +1,138 @@
|
||||
package webui
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"kjol/vdom"
|
||||
)
|
||||
|
||||
// Port of web/kit/CrmTabs.tsx.
|
||||
//
|
||||
// CrmTabGroup / CrmSubTabGroup are behaviourally-identical siblings of TabGroup
|
||||
// with a different look: CrmTabGroup is boxed tabs with a sky-blue top accent;
|
||||
// CrmSubTabGroup is a segmented control. Content sits flat beneath the tab row.
|
||||
//
|
||||
// NOTE: As in Tabs.tsx, the TSX localStorage persistence (storageKey) and
|
||||
// cross-component "storage"-event syncing are browser-only and dropped;
|
||||
// selection collapses to a plain ActiveIndex value + OnTabChange callback (the
|
||||
// caller owns state). The TSX defaultIndex prop collapses into ActiveIndex.
|
||||
|
||||
// CrmTabItem is one CRM tab: title, optional numeric badge (shown when > 0), and
|
||||
// inline panel content.
|
||||
type CrmTabItem struct {
|
||||
Title string
|
||||
Badge int
|
||||
Content *vdom.VNode
|
||||
}
|
||||
|
||||
// CrmTabGroupProps configures CrmTabGroup / CrmSubTabGroup. ActiveIndex is the
|
||||
// selected tab; clicking a tab calls OnTabChange with its index.
|
||||
type CrmTabGroupProps struct {
|
||||
Items []CrmTabItem
|
||||
ActiveIndex int
|
||||
OnTabChange func(int)
|
||||
}
|
||||
|
||||
// crmTabsPanels renders one wrapper div per item; only the active one is shown.
|
||||
func crmTabsPanels(p CrmTabGroupProps) []*vdom.VNode {
|
||||
out := make([]*vdom.VNode, 0, len(p.Items))
|
||||
for i, item := range p.Items {
|
||||
cls := "hidden"
|
||||
if i == p.ActiveIndex {
|
||||
cls = ""
|
||||
}
|
||||
out = append(out, vdom.El("div", vdom.Attr("class", cls), item.Content))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- CrmTabGroup: boxed top-accent tabs -------------------------------------
|
||||
|
||||
const crmTabRow = "flex w-full overflow-x-auto text-sm"
|
||||
const crmTabBase = "flex items-center gap-1.5 cursor-pointer p-4 font-medium border-neutral-300 transition-colors"
|
||||
const crmTabInactive = "border-b text-neutral-500 hover:text-neutral-800"
|
||||
const crmTabActive = "border-x border-t-2 border-t-sky-700 text-primary"
|
||||
const crmTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-primary text-white rounded-full"
|
||||
|
||||
// CrmTabGroup renders boxed tabs with a sky-blue top accent; content sits flat
|
||||
// beneath the row (no body panel), with a trailing filler extending the baseline.
|
||||
func CrmTabGroup(p CrmTabGroupProps) *vdom.VNode {
|
||||
row := []vdom.Mod{vdom.Attr("class", crmTabRow)}
|
||||
for i, item := range p.Items {
|
||||
idx := i
|
||||
state := crmTabInactive
|
||||
if i == p.ActiveIndex {
|
||||
state = crmTabActive
|
||||
}
|
||||
btn := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", cx(crmTabBase, 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", crmTabBadge), vdom.Text(strconv.Itoa(item.Badge))))
|
||||
}
|
||||
row = append(row, vdom.El("button", btn...))
|
||||
}
|
||||
row = append(row, vdom.El("div", vdom.Attr("class", "flex-1 border-b border-neutral-300")))
|
||||
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", "w-full"),
|
||||
vdom.El("div", row...),
|
||||
vdom.El("div", kids(nil, crmTabsPanels(p))...),
|
||||
)
|
||||
}
|
||||
|
||||
// --- CrmSubTabGroup: segmented control --------------------------------------
|
||||
|
||||
const crmSubTabWrap = "flex pb-3 border-b border-neutral-300 overflow-x-auto"
|
||||
const crmSubTabGroup = "inline-flex items-stretch rounded-md border border-neutral-300 overflow-hidden text-sm select-none"
|
||||
const crmSubTabBase = "flex items-center gap-1.5 py-1 px-3 cursor-pointer font-medium whitespace-nowrap transition-colors"
|
||||
const crmSubTabDivider = "border-l border-neutral-300"
|
||||
const crmSubTabActive = "bg-neutral-500 text-white"
|
||||
const crmSubTabInactive = "bg-white text-neutral-600 hover:bg-neutral-100 hover:text-neutral-900"
|
||||
const crmSubTabBadge = "inline-flex items-center justify-center min-w-5 h-5 px-1 text-xs font-semibold bg-black/10 text-current rounded-full"
|
||||
|
||||
// CrmSubTabGroup renders a left-aligned segmented control (interlocking
|
||||
// segments) with a full-width baseline separating the bar from the content.
|
||||
func CrmSubTabGroup(p CrmTabGroupProps) *vdom.VNode {
|
||||
group := []vdom.Mod{vdom.Attr("class", crmSubTabGroup)}
|
||||
for i, item := range p.Items {
|
||||
idx := i
|
||||
divider := ""
|
||||
if i > 0 {
|
||||
divider = crmSubTabDivider
|
||||
}
|
||||
state := crmSubTabInactive
|
||||
if i == p.ActiveIndex {
|
||||
state = crmSubTabActive
|
||||
}
|
||||
btn := []vdom.Mod{
|
||||
vdom.Attr("type", "button"),
|
||||
vdom.Attr("class", cx(crmSubTabBase, divider, 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", crmSubTabBadge), vdom.Text(strconv.Itoa(item.Badge))))
|
||||
}
|
||||
group = append(group, vdom.El("button", btn...))
|
||||
}
|
||||
return vdom.El("div",
|
||||
vdom.Attr("class", "w-full pt-3"),
|
||||
vdom.El("div",
|
||||
vdom.Attr("class", crmSubTabWrap),
|
||||
vdom.El("div", group...),
|
||||
),
|
||||
vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", "pt-3")}, crmTabsPanels(p))...),
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user