initial port of the UI kit

This commit is contained in:
2026-07-13 01:58:29 -04:00
parent 261a7f2b4d
commit b02df48a66
51 changed files with 7507 additions and 154 deletions

131
go/webui/accordion.go Normal file
View File

@@ -0,0 +1,131 @@
package webui
import "kjol/vdom"
// Port of web/kit/Accordion.tsx.
//
// NOTE: The TSX AccordionItem kept its open state in an internal signal (seeded
// by startOpen, synced from an optional isOpen prop via createEffect). Per the
// porting guide, selection state collapses to a plain value + callback: IsOpen
// is a plain bool and OnToggle fires on click (the caller flips its own state).
// The TSX startOpen prop collapses into the caller-supplied initial IsOpen /
// open[] / openIndex.
const accordionRootCls = "ui-accordion border border-neutral-200 rounded-default overflow-hidden"
const accordionItemCls = "ui-accordion-item border-b border-neutral-200 last:border-b-0"
const accordionTriggerCls = "ui-accordion-trigger flex items-center justify-between w-full py-3 px-4 text-left font-medium text-neutral-900 bg-neutral-50 cursor-pointer border-none transition-colors hover:bg-neutral-100 active:bg-neutral-200 focus:outline-hidden disabled:text-neutral-400 disabled:cursor-not-allowed disabled:bg-neutral-50"
const accordionTitleCls = "ui-accordion-title flex-1"
const accordionContentCls = "ui-accordion-content px-4 pb-4 text-neutral-700"
func accordionIconCls(open bool) string {
c := "ui-accordion-icon text-neutral-500 leading-none transition-transform duration-200"
if open {
c += " rotate-180"
}
return c
}
// AccordionItemProps configures a single AccordionItem. IsOpen is the current
// open state; OnToggle fires when the (enabled) trigger is clicked.
type AccordionItemProps struct {
IsOpen bool
Disabled bool
Title string
OnToggle func()
}
// AccordionItem is one collapsible row: a trigger button plus its content
// (children), shown when open and not disabled.
func AccordionItem(p AccordionItemProps, children ...*vdom.VNode) *vdom.VNode {
ariaExpanded := "false"
if p.IsOpen {
ariaExpanded = "true"
}
trigger := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", accordionTriggerCls),
vdom.Attr("aria-expanded", ariaExpanded),
vdom.On(vdom.EVENT_CLICK, func() {
if p.Disabled {
return
}
if p.OnToggle != nil {
p.OnToggle()
}
}),
vdom.El("span", vdom.Attr("class", accordionTitleCls), vdom.Text(p.Title)),
}
if p.Disabled {
trigger = append(trigger, vdom.Attr("disabled", "disabled"))
} else {
chev := "chevron-down"
if p.IsOpen {
chev = "chevron-up"
}
trigger = append(trigger, vdom.El("span",
vdom.Attr("class", accordionIconCls(p.IsOpen)),
Icon(chev, 18, ""),
))
}
mods := []vdom.Mod{vdom.Attr("class", accordionItemCls), vdom.El("button", trigger...)}
if p.IsOpen && !p.Disabled {
mods = append(mods, vdom.El("div", kids([]vdom.Mod{vdom.Attr("class", accordionContentCls)}, children)...))
}
return vdom.El("div", mods...)
}
// AccordionItemData is one entry for Accordion / SingleAccordion.
type AccordionItemData struct {
Title string
Content *vdom.VNode
Disabled bool
}
// Accordion renders independently-collapsible items. open[i] is item i's open
// state (missing entries are closed); clicking item i calls onToggle(i) — the
// caller flips open[i].
func Accordion(items []AccordionItemData, open []bool, onToggle func(int)) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", accordionRootCls)}
for i, item := range items {
idx := i
isOpen := i < len(open) && open[i]
mods = append(mods, AccordionItem(AccordionItemProps{
IsOpen: isOpen,
Disabled: item.Disabled,
Title: item.Title,
OnToggle: func() {
if onToggle != nil {
onToggle(idx)
}
},
}, item.Content))
}
return vdom.El("div", mods...)
}
// SingleAccordion renders items where at most one is open. openIndex is the open
// item (-1 = none); clicking an item calls onChange with the new open index —
// the clicked index, or -1 if that item was already open.
func SingleAccordion(items []AccordionItemData, openIndex int, onChange func(int)) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", accordionRootCls)}
for i, item := range items {
idx := i
mods = append(mods, AccordionItem(AccordionItemProps{
IsOpen: openIndex == i,
Disabled: item.Disabled,
Title: item.Title,
OnToggle: func() {
if onChange == nil {
return
}
if openIndex == idx {
onChange(-1)
} else {
onChange(idx)
}
},
}, item.Content))
}
return vdom.El("div", mods...)
}