// Port of web/kit/AutoTable.tsx.
//
// NOTE: AutoTable.tsx is ~4200 LOC and heavily coupled to the browser. This is a
// faithful, COMPILING *core* — the column model, the header/body/pagination
// render shell, and the Tailwind styling — not a behavioral clone. The following
// substantial features of the TSX are INTENTIONALLY OUT OF SCOPE here because
// they depend on DOM measurement, refs, timers, portals, or floating-ui, none of
// which exist in the neutral vdom runtime:
//
// - Virtual scrolling / windowed row rendering (rows are rendered eagerly).
// - Column drag-to-reorder and drag-to-resize (getBoundingClientRect, refs,
// mousemove tracking) — replaced by a static per-column WidthClass.
// - Computed/pinned table sizing (pinnedTableWidth measurement) — replaced by
// table-fixed + static WidthClass.
// - Runtime-editable calculated columns and footer summary rows, the whole
// Excel-style formula engine (tokenize/compile/highlight), and their popover
// editors (Popover/Menu/floating-ui).
// - CSV and PDF export (pdf-lib / pdfjs-dist), and the export/customize popover.
// - Remote data fetching (authFetch), remoteFiltering, refreshSignal.
// - Search/filter toolbar, quick-date presets, column-visibility toggles, the
// search-aside card, and localStorage persistence of order/width/columns.
// - Accordion expand/collapse rows and row-highlight auto-paging.
// - Client-side sorting/filtering/pagination logic (compareRowsGeneric,
// processDataLocally): sort state and pagination are surfaced here as plain
// value props + callbacks; the caller owns the actual sort/page computation.
//
// What IS ported: the AutoTableColumn model (header text, alignment, width class,
// sortable + a cell render func), the AutoTable render shell (sticky header,
// body rows, loading skeletons, error/empty states, display-only pagination),
// the verbatim Tailwind class constants/maps, and sort/pagination as props +
// callbacks. Reactive accessors collapse to plain values per the port guide.
package webui
import (
"strconv"
"kjol/vdom"
)
// ColumnPosition is a cell/header alignment (mirrors the TSX 0|1|2 union).
type ColumnPosition int
const (
COL_POS_LEFT ColumnPosition = 0
COL_POS_RIGHT ColumnPosition = 1
COL_POS_CENTER ColumnPosition = 2
)
// AutoTableHeaderColor selects the header/body color scheme.
type AutoTableHeaderColor int
const (
AUTOTABLE_HEADER_COLOR_DEFAULT AutoTableHeaderColor = 0
AUTOTABLE_HEADER_COLOR_BLUE AutoTableHeaderColor = 1
AUTOTABLE_HEADER_COLOR_GREEN AutoTableHeaderColor = 2
AUTOTABLE_HEADER_COLOR_GRAY AutoTableHeaderColor = 3
AUTOTABLE_HEADER_COLOR_DARK_BLUE AutoTableHeaderColor = 4
)
// AutoTableSize selects header/body/pagination density.
type AutoTableSize int
const (
AUTOTABLE_SIZE_DEFAULT AutoTableSize = 0
AUTOTABLE_SIZE_COMPACT AutoTableSize = 1
AUTOTABLE_SIZE_SUPERCOMPACT AutoTableSize = 2
)
// -- Tailwind class maps (copied verbatim from AutoTable.tsx) ---------------
// HEADER_COLOR_CLS is the background + text color per header color.
var HEADER_COLOR_CLS = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "bg-neutral-50",
AUTOTABLE_HEADER_COLOR_BLUE: "bg-sky-700 text-white",
AUTOTABLE_HEADER_COLOR_GREEN: "bg-green-700 text-white",
AUTOTABLE_HEADER_COLOR_GRAY: "bg-neutral-600 text-white",
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "bg-sky-900 text-white",
}
// atHeaderSortHoverCls is the sortable-hover override per color.
var atHeaderSortHoverCls = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-300",
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-500",
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-800",
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-500",
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-800",
}
// HEADER_TEXT_CLS is the header text weight/case per color.
var HEADER_TEXT_CLS = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "font-bold uppercase tracking-wider",
AUTOTABLE_HEADER_COLOR_BLUE: "font-semibold",
AUTOTABLE_HEADER_COLOR_GREEN: "font-semibold",
AUTOTABLE_HEADER_COLOR_GRAY: "font-semibold",
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "font-semibold",
}
// atHeaderSortIconCls is the sort-icon color (matches header text) per color.
var atHeaderSortIconCls = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "text-black",
AUTOTABLE_HEADER_COLOR_BLUE: "text-white",
AUTOTABLE_HEADER_COLOR_GREEN: "text-white",
AUTOTABLE_HEADER_COLOR_GRAY: "text-white",
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "text-white",
}
// HEADER_PADDING_CLS is the th padding per table size.
var HEADER_PADDING_CLS = map[AutoTableSize]string{
AUTOTABLE_SIZE_DEFAULT: "p-4 text-sm",
AUTOTABLE_SIZE_COMPACT: "py-1.5 px-2 text-sm",
AUTOTABLE_SIZE_SUPERCOMPACT: "py-1 px-2 text-xs",
}
// BODY_PADDING_CLS is the body cell padding per table size (applied via [&_td]:).
var BODY_PADDING_CLS = map[AutoTableSize]string{
AUTOTABLE_SIZE_DEFAULT: "text-sm [&_td]:p-4",
AUTOTABLE_SIZE_COMPACT: "text-sm [&_td]:py-1 [&_td]:px-2",
AUTOTABLE_SIZE_SUPERCOMPACT: "text-xs [&_td]:py-0.5 [&_td]:px-2",
}
// atPaginationPaddingCls is the pagination bar padding per table size.
var atPaginationPaddingCls = map[AutoTableSize]string{
AUTOTABLE_SIZE_DEFAULT: "py-3 px-4",
AUTOTABLE_SIZE_COMPACT: "py-1 px-4",
AUTOTABLE_SIZE_SUPERCOMPACT: "py-1 px-4",
}
// atRowHoverCls is the body-row hover background per color (when hover is on).
var atRowHoverCls = map[AutoTableHeaderColor]string{
AUTOTABLE_HEADER_COLOR_DEFAULT: "hover:bg-neutral-200",
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100",
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100",
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-neutral-200",
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100",
}
// POS_CLS is the text alignment per ColumnPosition.
var POS_CLS = map[ColumnPosition]string{
COL_POS_LEFT: "text-left",
COL_POS_RIGHT: "text-right",
COL_POS_CENTER: "text-center",
}
// HEADER_INNER_POS is the header inner flex direction per position.
var HEADER_INNER_POS = map[ColumnPosition]string{
COL_POS_LEFT: "",
COL_POS_RIGHT: "flex-row-reverse",
COL_POS_CENTER: "justify-center",
}
// -- Class string constants for parts that don't vary by config ------------
const (
TBL_CONTAINER = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"
TBL_WRAPPER = "overflow-x-auto w-full"
TBL_BASE = "min-w-full"
HEADER_CONTENT = "transition-transform duration-150 ease-in-out"
HEADER_INNER_BASE = "flex justify-between gap-2 items-center"
atSortIconWrap = "leading-none shrink-0 opacity-50"
atSkeleton = "h-4 bg-neutral-200 rounded-default animate-pulse"
atErrorCell = "text-center text-red-600"
atEmptyCell = "text-center text-neutral-500"
atPaginationBase = "flex justify-between items-center border-t border-neutral-300"
atPaginationInfo = "hidden sm:flex items-center text-sm text-neutral-500"
atPaginationControls = "flex items-center"
atPaginationLabel = "hidden sm:block text-sm text-neutral-500 mr-2"
atPaginationPage = "text-sm text-neutral-500 px-3"
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-neutral-100 disabled:text-neutral-300 disabled:cursor-not-allowed disabled:hover:bg-transparent"
// thead sticky classes are the STATIC replacement for the TSX's JS-driven
// header pinning (transform tracking on scroll). See file-level NOTE.
atTheadCls = "sticky top-0 z-10 [&_th]:border-b [&_th]:border-neutral-300"
)
// AutoTableColumn describes one column: its header text, alignment, an optional
// static Tailwind width class (the static replacement for the TSX's measured/
// resizable widths), whether it is sortable (and under which identifier), and a
// cell render func returning the full
for a given row. If Cell is nil an
// empty aligned
is rendered.
//
// NOTE: The TSX AutoTableColumn also carries csv/csvValue, sortType/sortValue,
// toggleable/hiddenByDefault, and a `calculated` spec — all tied to features that
// are out of scope here (export, local sort, toggles, calculated columns).
type AutoTableColumn struct {
DisplayName string
DisplayPosition ColumnPosition
WidthClass string // static Tailwind width, e.g. "w-32" (replaces measured sizing)
HeaderClasses string
Sortable bool
SortIdentifier string
Cell func(row any) *vdom.VNode
}
// AutoTableOrderBy is the active sort (mirrors the TSX interface).
type AutoTableOrderBy struct {
Identifier string
Descending bool
}
// AutoTablePagination is the display-only pagination state (mirrors the TSX
// interface; only the display fields are used by this core).
type AutoTablePagination struct {
CurrentPage int
TotalPages int
TotalItems int
MaxItemsPerPage int
ViewRangeLower int
ViewRangeUpper int
}
// atConfig holds resolved AutoTable options. Defaults mirror the TSX opts memo.
type atConfig struct {
size AutoTableSize
color AutoTableHeaderColor
shadow bool
hover bool
alternate bool
headerBorderY bool
surroundingBorder bool
borderX bool
borderY bool
tableLayoutAuto bool
hidePagination bool
loading bool
errorMsg string
emptyMessage string
sortIdentifier string
sortDescending bool
onSort func(identifier string)
pagination *AutoTablePagination
onPageChange func(page int)
onItemsPerPage func(n int)
class string
}
// AutoTableOption configures AutoTable (functional-options for the variadic opts).
type AutoTableOption func(*atConfig)
// AutoTableWithSize sets the density (default / compact / supercompact).
func AutoTableWithSize(s AutoTableSize) AutoTableOption {
return func(c *atConfig) { c.size = s }
}
// AutoTableWithColor sets the header/body color scheme.
func AutoTableWithColor(color AutoTableHeaderColor) AutoTableOption {
return func(c *atConfig) { c.color = color }
}
// AutoTableWithHover enables per-row hover highlighting.
func AutoTableWithHover() AutoTableOption { return func(c *atConfig) { c.hover = true } }
// AutoTableWithAlternate enables zebra striping on odd rows.
func AutoTableWithAlternate() AutoTableOption { return func(c *atConfig) { c.alternate = true } }
// AutoTableWithShadow adds a drop shadow to the table container.
func AutoTableWithShadow() AutoTableOption { return func(c *atConfig) { c.shadow = true } }
// AutoTableWithSurroundingBorder draws a border around the table container.
func AutoTableWithSurroundingBorder() AutoTableOption {
return func(c *atConfig) { c.surroundingBorder = true }
}
// AutoTableWithHeaderBorderY adds vertical dividers between header cells.
func AutoTableWithHeaderBorderY() AutoTableOption {
return func(c *atConfig) { c.headerBorderY = true }
}
// AutoTableWithBorderX draws horizontal dividers between body rows.
func AutoTableWithBorderX() AutoTableOption { return func(c *atConfig) { c.borderX = true } }
// AutoTableWithBorderY draws vertical dividers between body cells.
func AutoTableWithBorderY() AutoTableOption { return func(c *atConfig) { c.borderY = true } }
// AutoTableWithTableLayoutAuto uses auto table layout instead of table-fixed.
func AutoTableWithTableLayoutAuto() AutoTableOption {
return func(c *atConfig) { c.tableLayoutAuto = true }
}
// AutoTableWithLoading renders skeleton placeholder rows instead of data.
func AutoTableWithLoading(loading bool) AutoTableOption {
return func(c *atConfig) { c.loading = loading }
}
// AutoTableWithError renders a single error row with the given message.
func AutoTableWithError(msg string) AutoTableOption {
return func(c *atConfig) { c.errorMsg = msg }
}
// AutoTableWithEmptyMessage overrides the "No entries found." empty-state text.
func AutoTableWithEmptyMessage(msg string) AutoTableOption {
return func(c *atConfig) { c.emptyMessage = msg }
}
// AutoTableWithSort surfaces the active sort as a plain value + callback. onSort
// is invoked with a sortable column's SortIdentifier when its header is clicked;
// the caller owns the actual re-sorting (the TSX's local sort is out of scope).
func AutoTableWithSort(identifier string, descending bool, onSort func(identifier string)) AutoTableOption {
return func(c *atConfig) {
c.sortIdentifier = identifier
c.sortDescending = descending
c.onSort = onSort
}
}
// AutoTableWithPagination surfaces display-only pagination state + callbacks. The
// caller owns the actual paging/query computation.
func AutoTableWithPagination(p *AutoTablePagination, onPageChange func(page int), onItemsPerPage func(n int)) AutoTableOption {
return func(c *atConfig) {
c.pagination = p
c.onPageChange = onPageChange
c.onItemsPerPage = onItemsPerPage
}
}
// AutoTableWithHidePagination hides the pagination bar.
func AutoTableWithHidePagination() AutoTableOption {
return func(c *atConfig) { c.hidePagination = true }
}
// AutoTableWithClass appends classes to the outermost wrapper.
func AutoTableWithClass(class string) AutoTableOption {
return func(c *atConfig) { c.class = class }
}
// AutoTable renders the table shell for the given columns and rows. Sort state,
// loading, and pagination are plain value props supplied via opts (reactive
// accessors collapse). See the file-level NOTE for out-of-scope features.
func AutoTable(cols []AutoTableColumn, rows []any, opts ...AutoTableOption) *vdom.VNode {
cfg := &atConfig{}
for _, o := range opts {
o(cfg)
}
tableCls := cx("border-collapse", TBL_BASE)
if !cfg.tableLayoutAuto {
tableCls = cx(tableCls, "table-fixed")
}
containerCls := TBL_CONTAINER
if cfg.surroundingBorder {
containerCls = cx(containerCls, "border border-neutral-300")
}
if cfg.shadow {
containerCls = cx(containerCls, "shadow-sm")
}
table := vdom.El("table",
vdom.Attr("class", tableCls),
atRenderHead(cols, cfg),
atRenderBody(cols, rows, cfg),
)
container := vdom.El("div", vdom.Attr("class", containerCls),
vdom.El("div", vdom.Attr("class", TBL_WRAPPER), table),
)
if footer := atRenderPagination(cfg); footer != nil {
container.Children = append(container.Children, footer)
}
return vdom.El("div",
vdom.Attr("class", cx("min-w-0 w-full max-w-full", cfg.class)),
container,
)
}
// atRenderHead builds the with one header
.
func atRenderHead(cols []AutoTableColumn, cfg *atConfig) *vdom.VNode {
headerColor := HEADER_COLOR_CLS[cfg.color]
headerPadding := HEADER_PADDING_CLS[cfg.size]
tr := vdom.El("tr")
if len(cols) == 0 {
tr.Children = append(tr.Children,
vdom.El("th", vdom.Attr("class", cx(headerPadding, headerColor)), vdom.Text(" ")))
}
for i, col := range cols {
tr.Children = append(tr.Children, atRenderHeaderCell(col, i, cfg, headerColor, headerPadding))
}
return vdom.El("thead", vdom.Attr("class", atTheadCls), tr)
}
// atRenderHeaderCell builds one
. Drag/resize handles are out of scope.
func atRenderHeaderCell(col AutoTableColumn, displayIdx int, cfg *atConfig, headerColor, headerPadding string) *vdom.VNode {
pos := col.DisplayPosition
posCls := POS_CLS[pos]
thCls := cx(headerPadding, headerColor, col.WidthClass, posCls)
if cfg.headerBorderY && displayIdx > 0 {
thCls = cx(thCls, "border-l border-l-neutral-300")
}
if col.Sortable {
thCls = cx(thCls, "cursor-pointer", atHeaderSortHoverCls[cfg.color])
}
thCls = cx(thCls, col.HeaderClasses)
mods := []vdom.Mod{vdom.Attr("class", thCls)}
if col.Sortable {
sortID := col.SortIdentifier
if sortID != "" && cfg.onSort != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { cfg.onSort(sortID) }))
}
}
// Inner content: label (grows) + sort caret.
inner := vdom.El("div", vdom.Attr("class", cx(HEADER_INNER_BASE, HEADER_INNER_POS[pos])),
vdom.El("div", vdom.Attr("class", cx("grow text-sm", HEADER_TEXT_CLS[cfg.color])), vdom.Text(col.DisplayName)),
)
if col.Sortable {
iconWrap := vdom.El("div", vdom.Attr("class", cx(atSortIconWrap, "w-4 text-center", atHeaderSortIconCls[cfg.color])))
if col.SortIdentifier != "" && cfg.sortIdentifier == col.SortIdentifier {
// caret-up/caret-down are not in the default icon registry, so they
// render as empty boxes until an app registers them (see Icons.go).
if cfg.sortDescending {
iconWrap.Children = append(iconWrap.Children, Icon("caret-down", 16, ""))
} else {
iconWrap.Children = append(iconWrap.Children, Icon("caret-up", 16, ""))
}
}
inner.Children = append(inner.Children, iconWrap)
}
content := vdom.El("div", vdom.Attr("class", HEADER_CONTENT), inner)
mods = append(mods, content)
return vdom.El("th", mods...)
}
// atRenderBody builds the
with its loading / error / empty / data states.
func atRenderBody(cols []AutoTableColumn, rows []any, cfg *atConfig) *vdom.VNode {
bodyCls := BODY_PADDING_CLS[cfg.size]
if cfg.borderY {
bodyCls = cx(bodyCls, "[&_td+td]:border-l [&_td+td]:border-neutral-300")
}
colspan := len(cols)
if colspan == 0 {
colspan = 1
}
tbody := vdom.El("tbody", vdom.Attr("class", bodyCls))
switch {
case cfg.loading:
// NOTE: the TSX randomizes each skeleton's width; a fixed width is used
// here (no measurement/randomness in the neutral runtime).
for r := 0; r < 5; r++ {
tr := vdom.El("tr")
if cfg.alternate && r%2 == 1 {
tr.Attrs["class"] = "bg-neutral-100"
}
for range cols {
tr.Children = append(tr.Children, vdom.El("td",
vdom.El("div", vdom.Attr("class", atSkeleton), vdom.Attr("style", "width: 70%")),
))
}
tbody.Children = append(tbody.Children, tr)
}
case cfg.errorMsg != "":
tbody.Children = append(tbody.Children, vdom.El("tr",
vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atErrorCell),
vdom.Text("Error: "+cfg.errorMsg)),
))
case len(cols) == 0:
tbody.Children = append(tbody.Children, vdom.El("tr",
vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atEmptyCell),
vdom.Text("No columns selected.")),
))
case len(rows) == 0:
tbody.Children = append(tbody.Children, vdom.El("tr",
vdom.El("td", vdom.Attr("colspan", strconv.Itoa(colspan)),
vdom.Text(pick(cfg.emptyMessage, "No entries found."))),
))
default:
hoverCls := ""
if cfg.hover {
hoverCls = atRowHoverCls[cfg.color]
}
for rowIdx, row := range rows {
isLast := rowIdx == len(rows)-1
rowCls := ""
if cfg.alternate && rowIdx%2 == 1 {
rowCls = cx(rowCls, "bg-neutral-100")
}
rowCls = cx(rowCls, hoverCls)
if cfg.borderX && !isLast {
rowCls = cx(rowCls, "border-b border-neutral-300")
}
tr := vdom.El("tr")
if rowCls != "" {
tr.Attrs["class"] = rowCls
}
for _, col := range cols {
tr.Children = append(tr.Children, atRenderCell(col, row))
}
tbody.Children = append(tbody.Children, tr)
}
}
return tbody
}
// atRenderCell renders a column's cell for a row. The cell func returns the full
//
; if nil, an empty aligned
is produced.
func atRenderCell(col AutoTableColumn, row any) *vdom.VNode {
if col.Cell != nil {
if td := col.Cell(row); td != nil {
return td
}
}
return vdom.El("td", vdom.Attr("class", POS_CLS[col.DisplayPosition]))
}
// atRenderPagination builds the display-only pagination bar, or nil when there is
// nothing to show. Page/items-per-page changes are surfaced via callbacks.
func atRenderPagination(cfg *atConfig) *vdom.VNode {
if cfg.pagination == nil || cfg.hidePagination {
return nil
}
p := cfg.pagination
info := vdom.El("div", vdom.Attr("class", atPaginationInfo),
vdom.El("b", vdom.Attr("class", "leading-none"), Icon("list-ol", 16, "")),
vdom.El("span", vdom.Attr("class", "ml-3"),
vdom.Text(strconv.Itoa(p.ViewRangeLower)+"-"+strconv.Itoa(p.ViewRangeUpper)+" of "+strconv.Itoa(p.TotalItems))),
)
// NOTE: the TSX uses the ported FormSelect; a bare