// Port of jsruntime/uikit/AutoTable.tsx — the whole of it, in one file, as the original
// was. The banners below divide it into sections:
//
// - Render shell the column model, and the header/body/pagination markup
// - Data pipeline filter, sort, paginate (pure Go — no browser needed)
// - AutoTableState the controller: search, sort, page, expansion
// - Column management show-hide, drag-to-reorder, drag-to-resize, persistence
// - Calculated columns and summary rows, wired to the formula engine
// - Formula engine tokenizer, parser, evaluator (pure Go)
// - Export CSV, PDF, print (the PDF writer itself is in pdf.go)
//
// Most callers want AutoTableState, which owns the state and calls AutoTable for
// them; AutoTable itself stays usable directly when the caller already owns the
// filtering and paging.
//
// Anything that measures the page — resizing a column, dragging one, remembering
// either — reaches the browser through the host API in kjol/wasmruntime, which is
// dual-build (real in the browser, no-op stubs natively). That is what lets this
// file server-render: on the server every measurement is the zero Rect, no listener
// is installed, and the table renders in its declared column order.
//
// Two things in the original TSX are deliberately NOT reproduced:
//
// - Virtual scrolling. Neither the TSX nor this has it — pagination IS the
// windowing strategy. (An earlier note here claimed it was a missing feature;
// it never was one.)
// - JS-driven header pinning. The TSX has none either; the sticky thead below is
// an addition.
package webui
import (
"bytes"
"encoding/csv"
"encoding/json"
"errors"
"fmt"
"html"
"math"
"net/url"
"reflect"
"sort"
"strconv"
"strings"
"time"
"kjol/vdom"
"kjol/wasmruntime"
)
// 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-surface-muted",
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-surface-strong",
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-ink",
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-ss",
}
// 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-ss [&_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-surface-strong",
AUTOTABLE_HEADER_COLOR_BLUE: "hover:bg-sky-100 dark:bg-sky-900",
AUTOTABLE_HEADER_COLOR_GREEN: "hover:bg-green-100 dark:bg-green-900",
AUTOTABLE_HEADER_COLOR_GRAY: "hover:bg-surface-strong",
AUTOTABLE_HEADER_COLOR_DARK_BLUE: "hover:bg-sky-100 dark:bg-sky-900",
}
// 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-surface 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-surface-strong rounded-default animate-pulse"
atErrorCell = "text-center text-red-600 dark:text-red-400"
atEmptyCell = "text-center text-ink-muted"
atPaginationBase = "flex justify-between items-center border-t border-line-strong"
atPaginationInfo = "hidden sm:flex items-center text-sm text-ink-muted"
atPaginationControls = "flex items-center"
atPaginationLabel = "hidden sm:block text-sm text-ink-muted mr-2"
atPaginationPage = "text-sm text-ink-muted px-3"
atPaginationBtn = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-surface-raised disabled:text-ink-faint 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-line-strong"
)
// AutoTableColumn describes one column: its header, alignment, width, whether it
// sorts (and how), whether it exports, and how to render its cell.
type AutoTableColumn struct {
DisplayName string
DisplayPosition ColumnPosition
WidthClass string // Tailwind width, e.g. "w-32". A drag-resized column overrides it with an inline px width.
HeaderClasses string
Cell func(row any) *vdom.VNode // returns the whole
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-line-strong")
}
colspan := atTotalColumns(cols, cfg)
tbody := vdom.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.Tr()
if cfg.alternate && r%2 == 1 {
tr.Attrs["class"] = "bg-surface-raised"
}
if cfg.accordion {
tr.Children = append(tr.Children, vdom.Td(vdom.Attr("class", AUTOTABLE_ACCORDION_CELL)))
}
for range cols {
tr.Children = append(tr.Children, vdom.Td(vdom.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.Tr(vdom.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.Tr(vdom.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.Tr(vdom.Td(vdom.Attr("colspan", strconv.Itoa(colspan)), vdom.Attr("class", atEmptyCell),
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-surface-raised")
}
rowCls = cx(rowCls, hoverCls)
if cfg.borderX && !isLast {
rowCls = cx(rowCls, "border-b border-line-strong")
}
if cfg.highlight != nil && cfg.highlight(row) {
rowCls = cx(rowCls, AUTOTABLE_HIGHLIGHT_ROW)
}
key := ""
expanded := false
if cfg.accordion {
key = cfg.rowKey(row, rowIdx)
expanded = cfg.isExpanded != nil && cfg.isExpanded(key)
rowCls = cx(rowCls, "cursor-pointer")
}
tr := vdom.Tr()
if rowCls != "" {
tr.Attrs["class"] = rowCls
}
if cfg.accordion {
rowKey := key // capture per row, not the loop variable's final value
if cfg.onToggleExpand != nil {
tr.Events[vdom.EVENT_CLICK] = func(vdom.Event) { cfg.onToggleExpand(rowKey) }
}
tr.Children = append(tr.Children, atRenderAccordionToggle(expanded))
}
for _, col := range cols {
tr.Children = append(tr.Children, atRenderCell(col, row, cfg.rowOffset+rowIdx))
}
tbody.Children = append(tbody.Children, tr)
if expanded && cfg.accordionContent != nil {
tbody.Children = append(tbody.Children, vdom.Tr(vdom.Attr("class", AUTOTABLE_ACCORDION_ROW),
vdom.Td(vdom.Attr("colspan", strconv.Itoa(colspan)),
cfg.accordionContent(row),
),
))
}
}
}
return tbody
}
// atRenderAccordionToggle is the chevron cell. It rotates rather than swapping
// icons, so the transition reads as the row opening.
func atRenderAccordionToggle(expanded bool) *vdom.VNode {
rotate := "transition-transform duration-150"
if expanded {
rotate = cx(rotate, "rotate-90")
}
return vdom.Td(vdom.Attr("class", AUTOTABLE_ACCORDION_CELL),
vdom.Span(vdom.Attr("class", rotate), Icon("chevron-right", 14, "")),
)
}
// atRenderCell renders a column's cell. rowIndex is the row's position in the full
// filtered set, not on the page — a calculated column's running total has to keep
// counting across page boundaries.
func atRenderCell(col AutoTableColumn, row any, rowIndex int) *vdom.VNode {
if col.CellAt != nil {
if td := col.CellAt(row, rowIndex); td != nil {
return td
}
}
if col.Cell != nil {
if td := col.Cell(row); td != nil {
return td
}
}
return vdom.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 {
// The Reset menu lives in this bar, so it still needs a bar to live in when
// there is no pagination to show.
if cfg.reset != nil {
return vdom.Div(vdom.Attr("class", cx(atPaginationBase, atPaginationPaddingCls[cfg.size])),
cfg.reset,
)
}
return nil
}
p := cfg.pagination
info := vdom.Div(vdom.Attr("class", atPaginationInfo),
vdom.B(vdom.Attr("class", "leading-none"), Icon("list-ol", 16, "")),
vdom.Span(vdom.Attr("class", "ml-3"),
vdom.Text(strconv.Itoa(p.ViewRangeLower)+"-"+strconv.Itoa(p.ViewRangeUpper)+" of "+strconv.Itoa(p.TotalItems))),
)
sizes := []int{5, 10, 25, 50, 100}
if cfg.paginationShowAll {
sizes = append(sizes, PageSizeAll)
}
sel := vdom.Select(vdom.Attr("class", "mr-5"))
for _, n := range sizes {
label := strconv.Itoa(n)
if n == PageSizeAll {
label = "All"
}
optMods := []vdom.Mod{vdom.Attr("value", strconv.Itoa(n)), vdom.Text(label)}
if n == p.MaxItemsPerPage {
optMods = append(optMods, vdom.Attr("selected", "selected"))
}
sel.Children = append(sel.Children, vdom.Option(optMods...))
}
if cfg.onItemsPerPage != nil {
sel.Events[vdom.EVENT_CHANGE] = func(e vdom.Event) {
if n, err := strconv.Atoi(e.Value()); err == nil {
cfg.onItemsPerPage(n)
}
}
}
page := func(to int) func() {
return func() {
if cfg.onPageChange != nil {
cfg.onPageChange(to)
}
}
}
controls := vdom.Div(vdom.Attr("class", atPaginationControls),
vdom.Div(vdom.Attr("class", atPaginationLabel), vdom.Text("Items per page:")),
sel,
atPaginationButton(page(1), p.CurrentPage <= 1, Icon("angles-left", 16, "")),
atPaginationButton(page(p.CurrentPage-1), p.CurrentPage <= 1, Icon("chevron-left", 16, "")),
vdom.Div(vdom.Attr("class", atPaginationPage),
vdom.Text("Page "+strconv.Itoa(p.CurrentPage)+" of "+strconv.Itoa(p.TotalPages))),
atPaginationButton(page(p.CurrentPage+1), p.CurrentPage >= p.TotalPages, Icon("chevron-right", 16, "")),
atPaginationButton(page(p.TotalPages), p.CurrentPage >= p.TotalPages, Icon("angles-right", 16, "")),
)
// Bottom-left: Reset, then the "3-12 of 57" range. Bottom-right: the page
// controls. Reset belongs down here, next to the other table-wide controls,
// rather than up in the toolbar with the filters — it undoes the layout, not the
// query.
left := []vdom.Mod{vdom.Attr("class", "flex items-center gap-3")}
if cfg.reset != nil {
left = append(left, cfg.reset)
}
left = append(left, info)
return vdom.Div(vdom.Attr("class", cx(atPaginationBase, atPaginationPaddingCls[cfg.size])),
vdom.Div(left...),
controls,
)
}
// AutoTableWithResetMenu puts a Reset menu at the bottom-left of the table.
func AutoTableWithResetMenu(menu *vdom.VNode) AutoTableOption {
return func(c *atConfig) { c.reset = menu }
}
// atPaginationButton is one pagination control button (mirrors TSX PaginationButton).
func atPaginationButton(onClick func(), disabled bool, child *vdom.VNode) *vdom.VNode {
mods := []vdom.Mod{vdom.Attr("class", atPaginationBtn)}
if disabled {
mods = append(mods, vdom.Attr("disabled", "disabled"))
} else if onClick != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, onClick))
}
if child != nil {
mods = append(mods, child)
}
return vdom.Button(mods...)
}
// AutoTableTdLeft / AutoTableTdRight / AutoTableTdCenter build an aligned ,
// convenient for AutoTableColumn.Cell funcs (mirror the TSX TdLeft/Right/Center).
func AutoTableTdLeft(class string, children ...*vdom.VNode) *vdom.VNode {
return vdom.Td(kids([]vdom.Mod{vdom.Attr("class", cx("text-left", class))}, children)...)
}
func AutoTableTdRight(class string, children ...*vdom.VNode) *vdom.VNode {
return vdom.Td(kids([]vdom.Mod{vdom.Attr("class", cx("text-right", class))}, children)...)
}
func AutoTableTdCenter(class string, children ...*vdom.VNode) *vdom.VNode {
return vdom.Td(kids([]vdom.Mod{vdom.Attr("class", cx("text-center", class))}, children)...)
}
// ==========================================================================
// Data pipeline: filter, sort, paginate
// ==========================================================================
// AutoTable's data pipeline: filter, then sort, then paginate. Pure Go — no DOM,
// no browser — so it runs identically on the server (SSR) and in the client, and
// tests without a harness.
//
// This is the half of AutoTable.tsx that never actually needed a browser and was
// dropped anyway: the original Go port surfaced sort and pagination as callbacks
// and made every caller compute the result themselves.
//
// A note on "filtering": despite the name, the TSX has no filter-operator model —
// no gt/lt/between, no typed filter widgets. It has a flat list of search entries,
// each naming a field and one or more values, and exactly three behaviors (see
// ApplySearchFilters). Reproducing a richer model here would be inventing an API
// the two consuming apps do not use.
// AutoTableSearchEntry is one active filter: match `Identifier` against `Values`.
//
// - len(Values) > 1 -> the field must equal ONE OF the values (an IN-set test —
// this is what a multi-select filter produces). Case-insensitive.
// - len(Values) == 1 && Exact -> case-insensitive equality.
// - len(Values) == 1 -> case-insensitive substring match. The default.
//
// An Identifier built by MultiSearchIdentifier searches several fields at once,
// OR-ing across them — the closest thing the kit has to a global search box.
type AutoTableSearchEntry struct {
Identifier string
Values []string
Exact bool
}
// AutoTableFilter is the complete query state: what to match, how to order, and
// which page. It is what a remote endpoint receives (see BuildQueryString) and
// what ProcessLocally applies in memory.
type AutoTableFilter struct {
Search []AutoTableSearchEntry
OrderBy AutoTableOrderBy
Pagination AutoTablePagination
}
// multiSearchPrefix marks an identifier that spans several fields.
const multiSearchPrefix = "_multi_"
// MultiSearchIdentifier builds an identifier that matches a value against ANY of
// the given fields — one search box over "name, email, phone".
func MultiSearchIdentifier(fields ...string) string {
return multiSearchPrefix + strings.Join(fields, ",")
}
// SearchFields returns the fields an identifier covers: the several fields of a
// multi-search identifier, or the single field it names.
func SearchFields(identifier string) []string {
if rest, ok := strings.CutPrefix(identifier, multiSearchPrefix); ok {
return strings.Split(rest, ",")
}
return []string{identifier}
}
// PageSizeAll is the "All" page size: one page holding everything.
const PageSizeAll = -1
// ---- reading fields out of a row ----
// FieldReader pulls a named field out of a row. Rows are `any` — the app's own
// structs, or maps — so the pipeline needs a way to ask for "the Status field"
// without knowing the type. DefaultFieldReader handles the usual cases; supply
// your own for computed or nested fields.
type FieldReader func(row any, field string) any
// DefaultFieldReader reads a field from a map (map[string]any, map[string]string)
// or a struct (by exact field name, then `json` tag, then case-insensitive name —
// which is what makes a snake_case identifier from an API line up with a Go field).
// It follows pointers and returns nil when there is no such field.
func DefaultFieldReader(row any, field string) any {
v := reflect.ValueOf(row)
for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
if v.IsNil() {
return nil
}
v = v.Elem()
}
switch v.Kind() {
case reflect.Map:
key := reflect.ValueOf(field)
if !key.Type().AssignableTo(v.Type().Key()) {
return nil
}
got := v.MapIndex(key)
if !got.IsValid() {
return nil
}
return got.Interface()
case reflect.Struct:
t := v.Type()
if f, ok := t.FieldByName(field); ok && f.IsExported() {
return v.FieldByIndex(f.Index).Interface()
}
for i := range t.NumField() {
f := t.Field(i)
if !f.IsExported() {
continue
}
if tag, _, _ := strings.Cut(f.Tag.Get("json"), ","); tag == field {
return v.Field(i).Interface()
}
if strings.EqualFold(f.Name, field) {
return v.Field(i).Interface()
}
}
}
return nil
}
// nthField is the fallback for a sortable column with no explicit identifier: the
// TSX resolves `_col_` to the nth key of the first row.
//
// Divergence, deliberate: JavaScript objects have a stable key order, Go maps do
// not. For a struct we use declaration order (the natural analogue); for a map we
// sort the keys, so the result is at least deterministic across runs. Anything
// relying on `_col_` over a map was already relying on luck.
func nthField(row any, n int) any {
v := reflect.ValueOf(row)
for v.Kind() == reflect.Pointer || v.Kind() == reflect.Interface {
if v.IsNil() {
return nil
}
v = v.Elem()
}
switch v.Kind() {
case reflect.Struct:
t := v.Type()
var exported []int
for i := range t.NumField() {
if t.Field(i).IsExported() {
exported = append(exported, i)
}
}
if n < 0 || n >= len(exported) {
return nil
}
return v.Field(exported[n]).Interface()
case reflect.Map:
keys := make([]string, 0, v.Len())
for _, k := range v.MapKeys() {
keys = append(keys, k.String())
}
sort.Strings(keys)
if n < 0 || n >= len(keys) {
return nil
}
return v.MapIndex(reflect.ValueOf(keys[n])).Interface()
}
return nil
}
const positionalPrefix = "_col_"
// PositionalIdentifier names a column by index, for a table whose columns have no
// explicit sort identifiers.
func PositionalIdentifier(i int) string { return positionalPrefix + strconv.Itoa(i) }
// ---- filtering ----
// ApplySearchFilters keeps the rows matching every entry (entries AND together;
// the values within one entry OR together). An entry with no values is inert, so
// an empty search box does not filter everything away.
func ApplySearchFilters(rows []any, search []AutoTableSearchEntry, read FieldReader) []any {
if read == nil {
read = DefaultFieldReader
}
active := make([]AutoTableSearchEntry, 0, len(search))
for _, e := range search {
if e.Identifier == "" {
continue
}
vals := make([]string, 0, len(e.Values))
for _, v := range e.Values {
if strings.TrimSpace(v) != "" {
vals = append(vals, v)
}
}
if len(vals) > 0 {
active = append(active, AutoTableSearchEntry{Identifier: e.Identifier, Values: vals, Exact: e.Exact})
}
}
if len(active) == 0 {
return rows
}
out := make([]any, 0, len(rows))
for _, row := range rows {
if matchesAll(row, active, read) {
out = append(out, row)
}
}
return out
}
func matchesAll(row any, search []AutoTableSearchEntry, read FieldReader) bool {
for _, e := range search {
if !matchesEntry(row, e, read) {
return false
}
}
return true
}
// matchesEntry ORs across the entry's fields (a multi-search identifier covers
// several) and across its values.
func matchesEntry(row any, e AutoTableSearchEntry, read FieldReader) bool {
for _, field := range SearchFields(e.Identifier) {
cell := strings.ToLower(stringify(read(row, field)))
for _, want := range e.Values {
want = strings.ToLower(strings.TrimSpace(want))
switch {
case len(e.Values) > 1 || e.Exact:
if cell == want {
return true
}
default:
if strings.Contains(cell, want) {
return true
}
}
}
}
return false
}
// ---- sorting ----
// Sort types, mirroring the TSX's `sortType`.
const (
SortTypeString = "" // case-insensitive string compare (the default)
// SortTypeNumeric orders embedded numbers the way a person reads them:
// "Item 2" before "Item 10". Note it compares runs of digits, so "1.5" sorts
// like a version (1.5 < 1.10), not a decimal — for decimal strings use
// SortTypeMoney, or store a real numeric field.
SortTypeNumeric = "numeric"
SortTypeMoney = "money" // "$1,234.50" / "(1,234.50)" parsed as a number
)
// SortRows orders rows by the active sort. It is stable, so rows that compare
// equal keep their original order.
//
// Empty values ALWAYS sort last — in both directions. That is deliberate (and
// matches the TSX): reversing the sort should not drag a wall of blanks to the top.
func SortRows(rows []any, order AutoTableOrderBy, cols []AutoTableColumn, read FieldReader) []any {
if order.Identifier == "" {
return rows
}
if read == nil {
read = DefaultFieldReader
}
col, ok := columnFor(cols, order.Identifier)
sortType := SortTypeString
if ok {
sortType = col.SortType
}
key := func(row any) any { return sortKey(row, order.Identifier, col, ok, read) }
out := make([]any, len(rows))
copy(out, rows)
sort.SliceStable(out, func(i, j int) bool {
a, b := key(out[i]), key(out[j])
aEmpty, bEmpty := isEmptyValue(a), isEmptyValue(b)
if aEmpty != bEmpty {
return bEmpty // the non-empty one comes first, whatever the direction
}
if aEmpty {
return false
}
c := compareValues(a, b, sortType)
if order.Descending {
c = -c
}
return c < 0
})
return out
}
// sortKey resolves what a row's value for this sort actually is: an explicit
// SortValue func, else the named field, else a positional `_col_` reference.
func sortKey(row any, identifier string, col AutoTableColumn, haveCol bool, read FieldReader) any {
if haveCol && col.SortValue != nil {
return col.SortValue(row)
}
if n, ok := strings.CutPrefix(identifier, positionalPrefix); ok {
if i, err := strconv.Atoi(n); err == nil {
return nthField(row, i)
}
}
return read(row, identifier)
}
func columnFor(cols []AutoTableColumn, identifier string) (AutoTableColumn, bool) {
for _, c := range cols {
if c.SortIdentifier == identifier {
return c, true
}
}
return AutoTableColumn{}, false
}
func isEmptyValue(v any) bool {
switch t := v.(type) {
case nil:
return true
case string:
return strings.TrimSpace(t) == ""
case time.Time:
return t.IsZero()
}
rv := reflect.ValueOf(v)
if rv.Kind() == reflect.Pointer && rv.IsNil() {
return true
}
return false
}
// compareValues orders two non-empty values. Typed values (numbers, times, bools)
// compare natively; everything else falls back to the sort type.
func compareValues(a, b any, sortType string) int {
if at, ok := toTime(a); ok {
if bt, ok := toTime(b); ok {
return at.Compare(bt)
}
}
if af, ok := toFloat(a); ok {
if bf, ok := toFloat(b); ok {
return cmpFloat(af, bf)
}
}
if ab, ok := a.(bool); ok {
if bb, ok := b.(bool); ok {
return cmpBool(ab, bb)
}
}
as, bs := stringify(a), stringify(b)
switch sortType {
case SortTypeMoney:
return cmpFloat(parseMoney(as), parseMoney(bs))
case SortTypeNumeric:
return compareNatural(as, bs)
}
return compareFold(as, bs)
}
// compareNatural compares strings with embedded numbers the way a person would:
// "Item 2" before "Item 10", "A9" before "A10". It walks both strings together,
// comparing runs of digits numerically and everything else as text.
//
// Divergence from the TSX, deliberate: the original's "numeric" sort called
// parseInt on the whole value, which yields NaN for anything that does not START
// with a digit ("Item 10") and silently degrades to a string compare — so the
// original sorted Item 1, Item 10, Item 2. This is a superset: for values that are
// plain numbers ("10" vs "2") it agrees with the original exactly, and for the
// cases the original got wrong it now gets them right.
func compareNatural(a, b string) int {
i, j := 0, 0
for i < len(a) && j < len(b) {
ad, bd := isDigit(a[i]), isDigit(b[j])
if ad && bd {
ai, an := digitRun(a, i)
bj, bn := digitRun(b, j)
if c := cmpFloat(an, bn); c != 0 {
return c
}
i, j = ai, bj
continue
}
ca, cb := lowerByte(a[i]), lowerByte(b[j])
if ca != cb {
return cmpInt(int(ca), int(cb))
}
i++
j++
}
// One string is a prefix of the other: the shorter sorts first. If they are the
// same length here they matched case-insensitively, so break the tie on case to
// keep the ordering total.
if c := cmpInt(len(a)-i, len(b)-j); c != 0 {
return c
}
return strings.Compare(a, b)
}
// digitRun reads the number starting at i and returns the index just past it. It
// parses as a float so a run longer than an int64 does not wrap.
func digitRun(s string, i int) (int, float64) {
start := i
for i < len(s) && isDigit(s[i]) {
i++
}
n, _ := strconv.ParseFloat(s[start:i], 64)
return i, n
}
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
func lowerByte(c byte) byte {
if c >= 'A' && c <= 'Z' {
return c + ('a' - 'A')
}
return c
}
// compareFold compares case-insensitively, then case-sensitively to break ties, so
// the order is total and stable ("apple" and "Apple" never compare equal).
func compareFold(a, b string) int {
if c := strings.Compare(strings.ToLower(a), strings.ToLower(b)); c != 0 {
return c
}
return strings.Compare(a, b)
}
// parseMoney reads "$1,234.50" / "(1,234.50)" / "-1234.5" as a number. Unparseable
// input is 0, which sorts with the other zeroes rather than blowing up.
func parseMoney(s string) float64 {
s = strings.TrimSpace(s)
negative := strings.HasPrefix(s, "(") && strings.HasSuffix(s, ")")
s = strings.Map(func(r rune) rune {
if (r >= '0' && r <= '9') || r == '.' || r == '-' {
return r
}
return -1
}, s)
f, err := strconv.ParseFloat(s, 64)
if err != nil {
return 0
}
if negative {
return -f
}
return f
}
func toFloat(v any) (float64, bool) {
switch n := v.(type) {
case int:
return float64(n), true
case int8:
return float64(n), true
case int16:
return float64(n), true
case int32:
return float64(n), true
case int64:
return float64(n), true
case uint:
return float64(n), true
case uint8:
return float64(n), true
case uint16:
return float64(n), true
case uint32:
return float64(n), true
case uint64:
return float64(n), true
case float32:
return float64(n), true
case float64:
return n, true
}
return 0, false
}
func toTime(v any) (time.Time, bool) {
t, ok := v.(time.Time)
return t, ok
}
func cmpFloat(a, b float64) int {
switch {
case a < b:
return -1
case a > b:
return 1
}
return 0
}
func cmpInt(a, b int) int {
switch {
case a < b:
return -1
case a > b:
return 1
}
return 0
}
func cmpBool(a, b bool) int {
switch {
case a == b:
return 0
case !a:
return -1
}
return 1
}
// stringify renders a value the way the table displays it, which is what search
// and string-sorting compare against.
func stringify(v any) string {
switch t := v.(type) {
case nil:
return ""
case string:
return t
case bool:
return strconv.FormatBool(t)
case time.Time:
return t.Format("2006-01-02")
case float64:
return strconv.FormatFloat(t, 'f', -1, 64)
case float32:
return strconv.FormatFloat(float64(t), 'f', -1, 32)
}
if f, ok := toFloat(v); ok {
return strconv.FormatFloat(f, 'f', -1, 64)
}
if s, ok := v.(interface{ String() string }); ok {
return s.String()
}
return ""
}
// ---- pagination ----
// Paginate slices out one page and fills in the derived counts (total pages, the
// "3-12 of 57" view range). A MaxItemsPerPage of PageSizeAll yields a single page.
//
// It is defensive about the requested page: a filter that shrinks the result set
// can leave CurrentPage past the end, and silently showing an empty table is worse
// than showing the last page.
func Paginate(rows []any, p AutoTablePagination) ([]any, AutoTablePagination) {
out := p
out.TotalItems = len(rows)
perPage := p.MaxItemsPerPage
if perPage == PageSizeAll || perPage <= 0 {
out.TotalPages = 1
out.CurrentPage = 1
out.MaxItemsPerPage = perPage
out.ViewRangeLower, out.ViewRangeUpper = viewRange(len(rows), 1, len(rows))
return rows, out
}
out.TotalPages = max((len(rows)+perPage-1)/perPage, 1)
out.CurrentPage = clampInt(p.CurrentPage, 1, out.TotalPages)
lo := (out.CurrentPage - 1) * perPage
hi := min(lo+perPage, len(rows))
out.ViewRangeLower, out.ViewRangeUpper = viewRange(len(rows), lo+1, hi)
return rows[lo:hi], out
}
func viewRange(total, lo, hi int) (int, int) {
if total == 0 {
return 0, 0
}
return lo, hi
}
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
// ---- the whole pipeline ----
// ProcessLocally runs the full client-side pipeline — filter, sort, paginate — and
// returns the rows for the current page plus the resolved pagination state (total
// pages, view range) to hand back to the table.
//
// allFiltered is every row that survived the filter, before paging: the export
// path needs it (you export what you filtered, not what happens to be on screen),
// and so does anything that summarises the whole result set.
func ProcessLocally(rows []any, filter AutoTableFilter, cols []AutoTableColumn, read FieldReader) (page, allFiltered []any, pagination AutoTablePagination) {
allFiltered = ApplySearchFilters(rows, filter.Search, read)
allFiltered = SortRows(allFiltered, filter.OrderBy, cols, read)
page, pagination = Paginate(allFiltered, filter.Pagination)
return page, allFiltered, pagination
}
// ---- remote mode ----
// BuildQueryString encodes a filter for a server that does the filtering itself.
// Mirrors the TSX's buildQueryString: one query parameter per search identifier
// (repeated for a multi-value entry), plus order_by / order_desc / page_num /
// items_per_page.
//
// noPagination is what the export path passes: export the whole filtered set, not
// just the page currently on screen.
func BuildQueryString(filter AutoTableFilter, noPagination bool) string {
q := url.Values{}
for _, e := range filter.Search {
for _, v := range e.Values {
if strings.TrimSpace(v) == "" {
continue
}
q.Add(e.Identifier, v)
}
if e.Exact && len(e.Values) == 1 {
q.Set(e.Identifier+"_exact", "true")
}
}
if filter.OrderBy.Identifier != "" {
q.Set("order_by", filter.OrderBy.Identifier)
q.Set("order_desc", strconv.FormatBool(filter.OrderBy.Descending))
}
if noPagination {
q.Set("items_per_page", strconv.Itoa(PageSizeAll))
} else {
q.Set("page_num", strconv.Itoa(max(filter.Pagination.CurrentPage, 1)))
q.Set("items_per_page", strconv.Itoa(filter.Pagination.MaxItemsPerPage))
}
return q.Encode()
}
// ==========================================================================
// AutoTableState: the live controller
// ==========================================================================
// AutoTableState is the live controller for a table: it owns the search, sort,
// page and expansion state, runs the pipeline in autotable_data.go, and renders
// the result.
//
// The original Go port had no state at all — it surfaced sort and pagination as
// callbacks and left every caller to implement filtering, sorting and paging
// themselves. This is that missing half.
//
// Create it ONCE, alongside your signals, never inside a render function:
//
// table := webui.NewAutoTableState(columns, webui.AutoTableStateOptions{
// PerPage: 25,
// })
// table.SetRows(rows)
//
// return func() *vdom.VNode {
// return table.Render(
// webui.AutoTableWithHover(),
// webui.AutoTableWithSearchFields(
// table.TextSearch("Name", "Search names…"),
// table.SelectSearch("Status", []string{"active", "inactive"}, "Any status"),
// ),
// )
// }
type AutoTableState struct {
cols []AutoTableColumn
read FieldReader
opts AutoTableStateOptions
rows []any // the source rows, in local mode
search *vdom.Signal[[]AutoTableSearchEntry]
orderBy *vdom.Signal[AutoTableOrderBy]
page *vdom.Signal[int]
perPage *vdom.Signal[int]
expanded *vdom.Signal[map[string]bool]
filtersOpen *vdom.Signal[bool]
// Column layout (see autotable_columns.go).
order *vdom.Signal[[]string]
hidden *vdom.Signal[map[string]bool]
widths *vdom.Signal[map[string]float64]
dragKey *vdom.Signal[string]
dropKey *vdom.Signal[string]
// Calculated columns and summary rows are LIVE, not fixed at construction: the
// point of the editor is that a user builds them at runtime. Seeded from the
// options, then owned here.
calculated *vdom.Signal[[]UserCalculatedColumn]
summaries *vdom.Signal[[]UserSummaryRow]
editor *calcEditor // lazily built by CalculatedColumnEditor
resetMenu *Menu // lazily built by ResetMenu
// restored is false until the user's saved layout has been applied. It gates what
// is shown in the meantime — see RestoreLayout and pendingSkeleton.
restored *vdom.Signal[bool]
calcSeq int // generates IDs for user-created calc columns / summary rows
// thRefs memoizes header-cell refs, so a resize can measure them.
thRefs map[string]*vdom.Ref
// multiSelects memoizes the dropdown controllers (see multiSelect).
multiSelects map[string]*MultiSelect
// resolved by the last Render; kept so callers (export, toolbar actions) can ask
// what the current filter actually selected.
lastFiltered []any
lastPage AutoTablePagination
}
// AutoTableStateOptions configures the controller itself (as opposed to the
// table's appearance, which is still the AutoTableOption functional options).
type AutoTableStateOptions struct {
// PerPage is the initial page size. Zero means 25 (the TSX default);
// PageSizeAll shows everything on one page.
PerPage int
// Read overrides how a field is pulled out of a row. Defaults to
// DefaultFieldReader (maps, struct fields, json tags).
Read FieldReader
// Accordion turns rows into expandable ones. RowKey must be stable for a row
// across renders — it is what remembers which rows are open. AccordionContent
// renders the expanded panel.
Accordion bool
AccordionSingle bool // only one row open at a time
RowKey func(row any) string
AccordionContent func(row any) *vdom.VNode
// HighlightMatch flags a row visually. If the matching row is on another page,
// the table jumps to it — the point being to lead the user to a record they
// searched for elsewhere.
HighlightMatch func(row any) bool
// OnFilterChange fires whenever search/sort/page changes — for a caller that
// wants to mirror the filter into the URL, or drive a remote fetch.
OnFilterChange func(AutoTableFilter)
// Columns turns on the column picker, drag-to-reorder, drag-to-resize, and
// persistence of all three.
Columns AutoTableColumnOptions
// Calculated adds columns whose values come from the formula engine — an
// aggregation over other columns, or an Excel-style expression. They are
// evaluated against the FILTERED, SORTED rows, so a running total re-runs when
// you filter or re-sort. See autotable_calc.go.
Calculated []UserCalculatedColumn
// SummaryRows adds a line per entry, each evaluated once over the whole
// filtered set (not just the current page).
SummaryRows []UserSummaryRow
}
// NewAutoTableState creates the controller.
func NewAutoTableState(cols []AutoTableColumn, o AutoTableStateOptions) *AutoTableState {
if o.PerPage == 0 {
o.PerPage = 25
}
if o.Read == nil {
o.Read = DefaultFieldReader
}
s := &AutoTableState{
cols: cols,
read: o.Read,
opts: o,
search: vdom.NewSignal([]AutoTableSearchEntry{}),
orderBy: vdom.NewSignal(AutoTableOrderBy{}),
page: vdom.NewSignal(1),
perPage: vdom.NewSignal(o.PerPage),
expanded: vdom.NewSignal(map[string]bool{}),
filtersOpen: vdom.NewSignal(false),
order: vdom.NewSignal[[]string](nil),
hidden: vdom.NewSignal(map[string]bool{}),
widths: vdom.NewSignal(map[string]float64{}),
dragKey: vdom.NewSignal(""),
dropKey: vdom.NewSignal(""),
calculated: vdom.NewSignal(o.Calculated),
summaries: vdom.NewSignal(o.SummaryRows),
// Settled from the start unless a saved layout could still arrive and move
// things. A table with no StorageKey has no personal layout at all — it renders
// fully, server-side, exactly as declared. So does one that has opted out of
// waiting (ShowWhileRestoring).
restored: vdom.NewSignal(o.Columns.StorageKey == "" || o.Columns.ShowWhileRestoring),
}
s.hidden.Set(s.defaultHidden())
// The layout lives in localStorage, which the server cannot see. So the server
// renders the DECLARED layout, and the client can only apply the personal one on
// its first commit — see RestoreLayout, and `restored` for what the user sees in
// between.
wasmruntime.AfterRender(s.RestoreLayout)
return s
}
// SetRows replaces the source data. Call it when your data signal changes.
func (s *AutoTableState) SetRows(rows []any) { s.rows = rows }
// Columns returns the declared columns.
func (s *AutoTableState) Columns() []AutoTableColumn { return s.cols }
// Filter is the current query state — what to pass to a server in remote mode
// (see BuildQueryString), or to persist.
func (s *AutoTableState) Filter() AutoTableFilter {
return AutoTableFilter{
Search: s.search.Get(),
OrderBy: s.orderBy.Get(),
Pagination: AutoTablePagination{
CurrentPage: s.page.Get(),
MaxItemsPerPage: s.perPage.Get(),
},
}
}
// FilteredRows is every row matching the current filter, across all pages — what
// an export writes, and what a summary row totals. Valid after Render.
func (s *AutoTableState) FilteredRows() []any { return s.lastFiltered }
func (s *AutoTableState) changed() {
if s.opts.OnFilterChange != nil {
s.opts.OnFilterChange(s.Filter())
}
}
// ---- search ----
// SearchValue reads the single value for an identifier (the text in that box).
func (s *AutoTableState) SearchValue(identifier string) string {
for _, e := range s.search.Get() {
if e.Identifier == identifier && len(e.Values) > 0 {
return e.Values[0]
}
}
return ""
}
// SearchValues reads all values for an identifier (a multi-select's selection).
func (s *AutoTableState) SearchValues(identifier string) []string {
for _, e := range s.search.Get() {
if e.Identifier == identifier {
return e.Values
}
}
return nil
}
// SetSearchValue sets a single-value filter. An empty value REMOVES the entry
// rather than storing a blank one, so "cleared the box" and "never typed in it"
// are the same state — which is what makes ActiveFilterCount honest.
func (s *AutoTableState) SetSearchValue(identifier, value string, exact bool) {
if strings.TrimSpace(value) == "" {
s.SetSearchValues(identifier, nil, exact)
return
}
s.SetSearchValues(identifier, []string{value}, exact)
}
// SetSearchValues sets a multi-value (IN-set) filter. Empty values remove it.
func (s *AutoTableState) SetSearchValues(identifier string, values []string, exact bool) {
next := make([]AutoTableSearchEntry, 0, len(s.search.Get())+1)
for _, e := range s.search.Get() {
if e.Identifier != identifier {
next = append(next, e)
}
}
if len(values) > 0 {
next = append(next, AutoTableSearchEntry{Identifier: identifier, Values: values, Exact: exact})
}
s.search.Set(next)
s.page.Set(1) // a new filter invalidates the page you were on
s.clearExpanded()
s.changed()
}
// ActiveFilterCount is how many filters are actually doing something — the number
// on the badge next to the Filters button on mobile.
func (s *AutoTableState) ActiveFilterCount() int {
n := 0
for _, e := range s.search.Get() {
for _, v := range e.Values {
if strings.TrimSpace(v) != "" {
n++
break
}
}
}
return n
}
// ClearFilters drops every search entry.
func (s *AutoTableState) ClearFilters() {
s.search.Set(nil)
s.page.Set(1)
s.clearExpanded()
s.changed()
}
// FiltersOpen / ToggleFilters drive the collapsible filter panel on narrow screens.
func (s *AutoTableState) FiltersOpen() bool { return s.filtersOpen.Get() }
func (s *AutoTableState) ToggleFilters() { s.filtersOpen.Set(!s.filtersOpen.Get()) }
// ---- sort ----
// ToggleSort cycles a column's sort: unsorted -> ascending -> descending. Sorting
// resets to page 1 and collapses expanded rows, both of which would otherwise be
// pointing at rows that just moved.
func (s *AutoTableState) ToggleSort(identifier string) {
cur := s.orderBy.Get()
if cur.Identifier == identifier {
s.orderBy.Set(AutoTableOrderBy{Identifier: identifier, Descending: !cur.Descending})
} else {
s.orderBy.Set(AutoTableOrderBy{Identifier: identifier})
}
s.page.Set(1)
s.clearExpanded()
s.changed()
}
// OrderBy is the active sort.
func (s *AutoTableState) OrderBy() AutoTableOrderBy { return s.orderBy.Get() }
// ---- pagination ----
func (s *AutoTableState) SetPage(n int) {
s.page.Set(max(n, 1))
s.clearExpanded()
s.changed()
}
func (s *AutoTableState) SetPerPage(n int) {
s.perPage.Set(n)
s.page.Set(1)
s.clearExpanded()
s.changed()
}
// ---- expansion ----
func (s *AutoTableState) rowKey(row any, idx int) string {
if s.opts.RowKey != nil {
return s.opts.RowKey(row)
}
return strconv.Itoa(idx)
}
// IsExpanded reports whether a row's accordion panel is open.
func (s *AutoTableState) IsExpanded(key string) bool { return s.expanded.Get()[key] }
// ToggleExpanded opens or closes a row's panel.
func (s *AutoTableState) ToggleExpanded(key string) {
cur := s.expanded.Get()
next := map[string]bool{}
if !s.opts.AccordionSingle {
for k, v := range cur {
next[k] = v
}
}
if cur[key] {
delete(next, key)
} else {
next[key] = true
}
s.expanded.Set(next)
}
func (s *AutoTableState) clearExpanded() {
if len(s.expanded.Get()) > 0 {
s.expanded.Set(map[string]bool{})
}
}
// ---- the pipeline ----
// Process runs filter -> sort -> paginate against the current state, and jumps to
// the page holding the highlighted row if it is not on the current one.
func (s *AutoTableState) Process() (page []any, pagination AutoTablePagination) {
filtered := ApplySearchFilters(s.rows, s.search.Get(), s.read)
order := s.orderBy.Get()
if id, ok := strings.CutPrefix(order.Identifier, CalcRefPrefix); ok && len(s.Calculated()) > 0 {
// Sorting by a calculated column: its value is not in the row, so evaluate the
// column first and permute the rows to match (see sortByCalc).
_, values := s.evalCalcColumns(filtered)
if vals, ok := values[id]; ok {
filtered = sortByCalc(filtered, vals, order.Descending)
}
} else {
filtered = SortRows(filtered, order, s.cols, s.read)
}
// A highlighted row the user cannot see is useless — if the match landed on
// another page, go there. Done before slicing, on the sorted+filtered set, so
// the index is the one paging actually uses.
if s.opts.HighlightMatch != nil {
s.jumpToHighlight(filtered)
}
page, pagination = Paginate(filtered, AutoTablePagination{
CurrentPage: s.page.Get(),
MaxItemsPerPage: s.perPage.Get(),
})
s.lastFiltered = filtered
s.lastPage = pagination
return page, pagination
}
func (s *AutoTableState) jumpToHighlight(filtered []any) {
perPage := s.perPage.Get()
if perPage == PageSizeAll || perPage <= 0 {
return
}
for i, row := range filtered {
if !s.opts.HighlightMatch(row) {
continue
}
want := i/perPage + 1
if want != s.page.Get() {
s.page.Set(want)
}
return
}
}
// Render runs the pipeline and renders the table, with sort, pagination, accordion
// and highlighting already wired to this controller. Pass the usual AutoTableOption
// values for appearance; the state-driven ones are supplied here.
func (s *AutoTableState) Render(opts ...AutoTableOption) *vdom.VNode {
// The user's saved layout has not landed yet — which, on a server-rendered page,
// means the server does not know it either (it cannot read localStorage). Rendering
// the real table here would render the DEFAULT one, and the user would then watch
// their columns rearrange themselves once the wasm booted.
//
// So show a skeleton instead, and reveal the table once the layout is settled. The
// wait is the same; what they never see is the wrong table.
//
// It costs nothing on a client-rendered page: RestoreLayout runs on the first
// commit, before the browser has painted, so the skeleton is never actually seen.
if !s.restored.Get() {
return s.pendingSkeleton()
}
page, pagination := s.Process()
wired := []AutoTableOption{
AutoTableWithSort(s.orderBy.Get().Identifier, s.orderBy.Get().Descending, s.ToggleSort),
AutoTableWithPagination(&pagination, s.SetPage, s.SetPerPage),
AutoTableWithFiltersToggle(s.FiltersToggle(), s.FiltersOpen()),
AutoTableWithResetMenu(s.ResetMenu()),
}
if s.opts.Accordion {
wired = append(wired, AutoTableWithAccordion(
s.rowKey, s.IsExpanded, s.ToggleExpanded, s.opts.AccordionContent,
))
}
if s.opts.HighlightMatch != nil {
wired = append(wired, AutoTableWithHighlight(s.opts.HighlightMatch))
}
if h := s.columnHooks(); h != nil {
wired = append(wired, atWithColumnHooks(h))
}
cols := s.VisibleColumns()
if len(s.Calculated()) > 0 || len(s.SummaryRows()) > 0 {
// Evaluated against the FINAL row order, so ROW() and running totals count
// down the screen the way the user reads them.
ctx, values := s.evalCalcColumns(s.lastFiltered)
cols = append(cols, s.calcColumns(ctx, values)...)
if foot := s.summaryFoot(ctx, atTotalColumnsFor(cols, s.opts.Accordion)); foot != nil {
wired = append(wired, atWithFoot(foot))
}
// The page's first row is not row 0 of the filtered set — a running total on
// page 3 has to keep counting from where page 2 left off.
wired = append(wired, atWithRowOffset(max(pagination.ViewRangeLower-1, 0)))
}
// Caller options go last so they can override anything above.
return AutoTable(cols, page, append(wired, opts...)...)
}
// atTotalColumnsFor mirrors atTotalColumns without needing a built atConfig.
func atTotalColumnsFor(cols []AutoTableColumn, accordion bool) int {
n := max(len(cols), 1)
if accordion {
n++
}
return n
}
// ---- search field components ----
// TextSearch renders a search box bound to a field. Typing filters by substring.
func (s *AutoTableState) TextSearch(identifier, placeholder string) *vdom.VNode {
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
FormInput(FormInputProps{
Value: s.SearchValue(identifier),
Placeholder: placeholder,
OnInput: func(v string) { s.SetSearchValue(identifier, v, false) },
}),
)
}
// GlobalSearch renders one box that searches across several fields at once.
func (s *AutoTableState) GlobalSearch(placeholder string, fields ...string) *vdom.VNode {
return s.TextSearch(MultiSearchIdentifier(fields...), placeholder)
}
// SelectSearch renders a dropdown that filters by exact value. The empty option
// clears the filter — which is why SetSearchValue treats a blank as "remove the
// entry" rather than "match the empty string".
func (s *AutoTableState) SelectSearch(identifier string, values []string, anyLabel string) *vdom.VNode {
options := []*vdom.VNode{FormOption("", pick(anyLabel, "All"), false)}
for _, v := range values {
options = append(options, FormOption(v, v, false))
}
sel := FormSelect(FormSelectProps{
Value: s.SearchValue(identifier),
OnChange: func(v string) { s.SetSearchValue(identifier, v, true) },
}, options...)
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD), sel)
}
// MultiSelectSearch filters by an IN-set: a row matches if its value is any of the
// selected ones. This is the entry shape with several Values — and the reason
// ApplySearchFilters treats a multi-value entry as exact rather than substring
// (a substring test across a set would match far too much).
func (s *AutoTableState) MultiSelectSearch(identifier, placeholder string, values []string) *vdom.VNode {
options := make([]FormSelectOption, 0, len(values))
for _, v := range values {
options = append(options, FormSelectOption{Value: v, Label: v})
}
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
s.multiSelect(identifier).Render(FormMultiSelectProps{
Options: options,
Value: s.SearchValues(identifier),
Placeholder: pick(placeholder, "Any"),
Searchable: true,
ShowSelectAll: true,
OnChange: func(vs []string) { s.SetSearchValues(identifier, vs, true) },
}),
)
}
// multiSelect memoizes a dropdown controller, keyed by identifier.
//
// It MUST be memoized. A controller built inside a render would get fresh refs and a
// fresh closed state every frame — the dropdown could never stay open, and the
// outside-click listener would be watching an element that no longer exists.
func (s *AutoTableState) multiSelect(key string) *MultiSelect {
if s.multiSelects == nil {
s.multiSelects = map[string]*MultiSelect{}
}
ms, ok := s.multiSelects[key]
if !ok {
ms = NewMultiSelect(DropdownOptions{})
s.multiSelects[key] = ms
}
return ms
}
// DateSearch renders a date field bound to an identifier. A date RANGE is two of
// these under two identifiers (e.g. "hired_from" and "hired_to") — which is how
// the TSX does it, resolved server-side.
func (s *AutoTableState) DateSearch(identifier, label string) *vdom.VNode {
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
FormInput(FormInputProps{
Type: "date",
Value: s.SearchValue(identifier),
Placeholder: label,
OnInput: func(v string) { s.SetSearchValue(identifier, v, false) },
}),
)
}
// FiltersToggle is the button that reveals the filter panel on narrow screens. It
// carries a badge with the number of filters currently doing something, so a user
// who scrolled past a collapsed panel can still see that the table is filtered.
func (s *AutoTableState) FiltersToggle() *vdom.VNode {
mods := []vdom.Mod{
vdom.Attr("type", "button"),
vdom.Attr("class", AUTOTABLE_FILTERS_TOGGLE),
vdom.On(vdom.EVENT_CLICK, s.ToggleFilters),
Icon("filter", 16, ""),
vdom.Span(vdom.Text("Filters")),
}
if n := s.ActiveFilterCount(); n > 0 {
mods = append(mods, vdom.Span(vdom.Attr("class", AUTOTABLE_FILTERS_BADGE),
vdom.Text(strconv.Itoa(n)),
))
}
return vdom.Button(mods...)
}
// Tailwind for the search/filter chrome, copied from AutoTable.tsx.
const (
AUTOTABLE_SEARCH_FIELDS = "flex flex-wrap items-end gap-2"
AUTOTABLE_SEARCH_FIELD = "flex flex-col gap-1 min-w-0 grow sm:grow-0 sm:w-48"
AUTOTABLE_SEARCH_SELECT = "w-full rounded-default border border-line-strong bg-surface px-3 py-2 text-sm"
AUTOTABLE_FILTERS_TOGGLE = "sm:hidden inline-flex items-center gap-2 rounded-default border border-line-strong px-3 py-2 text-sm"
AUTOTABLE_FILTERS_BADGE = "ml-1 inline-flex h-5 min-w-5 items-center justify-center rounded-full bg-sky-600 px-1.5 text-ss font-semibold text-white"
AUTOTABLE_TOOLBAR = "flex flex-wrap items-end justify-between gap-3 pb-3"
AUTOTABLE_ASIDE = "w-full sm:w-64 shrink-0 rounded-default border border-line-strong bg-surface p-3"
AUTOTABLE_ACCORDION_CELL = "w-10 text-center"
AUTOTABLE_ACCORDION_ROW = "bg-surface-muted"
AUTOTABLE_HIGHLIGHT_ROW = "bg-amber-100 dark:bg-amber-900! shadow-[inset_3px_0_0_0_theme(colors.amber.500)]"
)
// ==========================================================================
// Column management: visibility, reorder, resize, persistence
// ==========================================================================
// Column management: which columns are shown, in what order, and how wide — plus
// remembering all three across reloads.
//
// This is the part of AutoTable that genuinely could not be ported before. Resizing
// needs to measure the real header cells (getBoundingClientRect), track the pointer
// through document-level mousemove/mouseup, and read event.clientX; reordering needs
// HTML5 drag events and a DataTransfer; and remembering any of it needs
// localStorage. None of that existed in the neutral runtime until now.
//
// The one design decision worth calling out: while a resize drag is IN FLIGHT, the
// widths are written straight to the DOM with SetStyle. They are only committed to
// a signal (and to storage) on mouseup. Driving the drag through a signal would
// re-render the entire application on every mousemove frame.
// MinColumnWidth is the narrowest a column can be dragged, in px.
const MinColumnWidth = 56
// AutoTableColumnOptions turns on column management. It is part of
// AutoTableStateOptions.
type AutoTableColumnOptions struct {
// Toggleable shows a column picker; a column must also set Toggleable itself to
// appear in it (so a table can pin its identifying column on).
Toggleable bool
// Draggable lets the user reorder columns by dragging their headers.
Draggable bool
// Resizable puts a drag handle on each header's right edge.
Resizable bool
// StorageKey persists the layout under a key derived from it. Empty means do not
// persist — and a table that does not persist has nothing that can arrive late, so
// it renders in full, server-side, exactly as declared. Every column must have a
// stable Key for persistence to mean anything; an index-based key would scramble
// the moment the columns are reordered.
StorageKey string
// ShowWhileRestoring renders the DECLARED table immediately, instead of holding a
// skeleton until the user's saved layout has been read.
//
// The trade it makes: the saved layout lives in localStorage, which the server
// cannot read, so a server-rendered table can either show the declared layout at
// once (and rearrange itself when the client applies the personal one — the flash)
// or show nothing until it knows (a skeleton, then the right table). The default is
// the second. Set this to choose the first — worth it when the page is public, or
// SEO matters, or the layout rarely differs enough to be jarring.
//
// It changes nothing for a table with no StorageKey: that one has no personal
// layout to wait for, and always renders in full.
ShowWhileRestoring bool
}
// ---- keys ----
// ColumnKey identifies a column for ordering, hiding and sizing. It falls back to
// the index, which is fine for a table that never persists or reorders and wrong
// for one that does — hence the warning on AutoTableColumn.Key.
func ColumnKey(col AutoTableColumn, i int) string {
if col.Key != "" {
return col.Key
}
return strconv.Itoa(i)
}
func (s *AutoTableState) columnKey(col AutoTableColumn, i int) string { return ColumnKey(col, i) }
// thRef memoizes a header cell's ref. Memoized because a resize has to measure the
// cells, and a ref rebuilt each render would never be attached to anything by the
// time the drag reads it.
func (s *AutoTableState) thRef(key string) *vdom.Ref {
if s.thRefs == nil {
s.thRefs = map[string]*vdom.Ref{}
}
r, ok := s.thRefs[key]
if !ok {
r = vdom.NewRef()
s.thRefs[key] = r
}
return r
}
// ---- visible columns ----
// VisibleColumns applies the user's order and hides what they have hidden. It is
// what Render actually draws, and what an export writes.
func (s *AutoTableState) VisibleColumns() []AutoTableColumn {
hidden := s.hidden.Get()
order := s.order.Get()
byKey := make(map[string]AutoTableColumn, len(s.cols))
for i, c := range s.cols {
byKey[s.columnKey(c, i)] = c
}
out := make([]AutoTableColumn, 0, len(s.cols))
seen := map[string]bool{}
// The saved order first — but only for columns that still exist. A persisted
// order outlives the code that produced it, so a column that has since been
// deleted must not resurrect, and one that has since been ADDED must still show
// up (below), rather than silently vanishing because it is not in the old list.
for _, k := range order {
if c, ok := byKey[k]; ok && !hidden[k] && !seen[k] {
out = append(out, c)
seen[k] = true
}
}
for i, c := range s.cols {
k := s.columnKey(c, i)
if seen[k] || hidden[k] {
continue
}
out = append(out, c)
seen[k] = true
}
return out
}
// HiddenColumns reports which columns are currently hidden.
func (s *AutoTableState) HiddenColumns() map[string]bool { return s.hidden.Get() }
// ToggleColumn shows or hides a column. A column with Toggleable false cannot be
// hidden — it is the table's identifying column and hiding it would leave rows
// unrecognisable.
func (s *AutoTableState) ToggleColumn(key string) {
for i, c := range s.cols {
if s.columnKey(c, i) == key && !c.Toggleable {
return
}
}
next := map[string]bool{}
for k, v := range s.hidden.Get() {
next[k] = v
}
if next[key] {
delete(next, key)
} else {
next[key] = true
}
s.hidden.Set(next)
s.persistColumns()
}
// ---- reordering ----
// MoveColumn moves the column with key `from` to the position of `to`.
func (s *AutoTableState) MoveColumn(from, to string) {
if from == to || from == "" || to == "" {
return
}
order := s.currentOrder()
fromIdx, toIdx := indexOf(order, from), indexOf(order, to)
if fromIdx < 0 || toIdx < 0 {
return
}
moved := append(order[:fromIdx:fromIdx], order[fromIdx+1:]...)
// Where the target sits once the dragged column has been pulled out — removing it
// shifts everything to its right left by one, so the pre-removal index is stale.
insertAt := indexOf(moved, to)
// Dragging rightward means dropping AFTER the target; dragging leftward, before
// it. Compare the ORIGINAL indices: comparing against the recomputed one makes an
// adjacent forward move a no-op.
if fromIdx < toIdx {
insertAt++
}
next := make([]string, 0, len(order))
next = append(next, moved[:insertAt]...)
next = append(next, from)
next = append(next, moved[insertAt:]...)
s.order.Set(next)
s.persistColumns()
}
// currentOrder is the saved order, backfilled with any columns it does not mention
// (newly added ones).
func (s *AutoTableState) currentOrder() []string {
saved := s.order.Get()
out := make([]string, 0, len(s.cols))
seen := map[string]bool{}
for _, k := range saved {
for i, c := range s.cols {
if s.columnKey(c, i) == k && !seen[k] {
out = append(out, k)
seen[k] = true
}
}
}
for i, c := range s.cols {
if k := s.columnKey(c, i); !seen[k] {
out = append(out, k)
seen[k] = true
}
}
return out
}
func indexOf(xs []string, x string) int {
for i, v := range xs {
if v == x {
return i
}
}
return -1
}
// ---- resizing ----
// ColumnWidth is a column's user-set width in px, or 0 when it has never been
// resized (in which case its Tailwind WidthClass applies).
func (s *AutoTableState) ColumnWidth(key string) float64 { return s.widths.Get()[key] }
// beginResize starts a drag on a column's right edge. It measures every header cell
// up front (widths must be known in px before they can be traded), then tracks the
// pointer at the document level — the cursor leaves the 4px handle immediately, so
// listening on the handle itself would drop the drag the moment it started.
func (s *AutoTableState) beginResize(key string, startX int) {
visible := s.VisibleColumns()
idx := -1
for i, c := range visible {
if s.columnKey(c, i) == key {
idx = i
break
}
}
if idx < 0 || idx+1 >= len(visible) {
return // the last column has no neighbour to trade width with
}
neighbourKey := s.columnKey(visible[idx+1], idx+1)
selfRef, neighbourRef := s.thRef(key), s.thRef(neighbourKey)
startSelf := wasmruntime.Measure(selfRef).Width
startNeighbour := wasmruntime.Measure(neighbourRef).Width
if startSelf == 0 || startNeighbour == 0 {
return // not laid out (or we are on the server); nothing to resize
}
var stopMove, stopUp wasmruntime.Unsub
finish := func() {
if stopMove != nil {
stopMove()
}
if stopUp != nil {
stopUp()
}
}
// Live: write straight to the DOM. Committing to a signal here would re-render
// the whole app on every mousemove.
var selfW, neighbourW float64
stopMove = wasmruntime.OnDocument(vdom.EVENT_MOUSEMOVE, false, func(e vdom.Event) {
delta := float64(e.ClientX() - startX)
// Clamp so neither side goes below the minimum. The pair's total is preserved,
// so the table's overall width never changes mid-drag.
delta = clamp(delta, MinColumnWidth-startSelf, startNeighbour-MinColumnWidth)
selfW = startSelf + delta
neighbourW = startNeighbour - delta
wasmruntime.SetStyle(selfRef, "width", px(selfW))
wasmruntime.SetStyle(neighbourRef, "width", px(neighbourW))
})
// Commit once, on release: one re-render, one write to storage.
stopUp = wasmruntime.OnDocument(vdom.EVENT_MOUSEUP, false, func(vdom.Event) {
finish()
if selfW == 0 {
return // a click with no movement
}
next := map[string]float64{}
for k, v := range s.widths.Get() {
next[k] = v
}
next[key] = selfW
next[neighbourKey] = neighbourW
s.widths.Set(next)
s.persistColumns()
})
}
// ResetColumns restores the declared order, visibility and widths, and forgets the
// persisted ones.
func (s *AutoTableState) ResetColumns() {
s.order.Set(nil)
s.hidden.Set(s.defaultHidden())
s.widths.Set(map[string]float64{})
s.persistColumns()
}
// The reset actions, one per thing a user can customise. They are separate because
// they are separate decisions: someone who dragged a column three seats to the left
// and then wrote a formula wants to undo one of those, not both.
// ResetColumnOrder restores the declared column order.
func (s *AutoTableState) ResetColumnOrder() {
s.order.Set(nil)
s.persistColumns()
}
// ResetColumnWidths drops every dragged width, so columns size themselves again.
func (s *AutoTableState) ResetColumnWidths() {
s.widths.Set(map[string]float64{})
s.persistColumns()
}
// ResetColumnVisibility shows every toggleable column again (minus the ones declared
// HiddenByDefault, which are part of the default, not a user choice).
func (s *AutoTableState) ResetColumnVisibility() {
s.hidden.Set(s.defaultHidden())
s.persistColumns()
}
// ResetCalculated deletes every calculated column, and clears the sort if it pointed
// at one — a sort naming a column that no longer exists silently stops sorting.
func (s *AutoTableState) ResetCalculated() {
if strings.HasPrefix(s.orderBy.Get().Identifier, CalcRefPrefix) {
s.orderBy.Set(AutoTableOrderBy{})
}
s.calculated.Set(nil)
s.persistColumns()
}
// ResetSummaryRows deletes every footer row.
func (s *AutoTableState) ResetSummaryRows() {
s.summaries.Set(nil)
s.persistColumns()
}
// ResetAll restores everything the user has customised — layout and formulas both.
func (s *AutoTableState) ResetAll() {
if strings.HasPrefix(s.orderBy.Get().Identifier, CalcRefPrefix) {
s.orderBy.Set(AutoTableOrderBy{})
}
s.order.Set(nil)
s.hidden.Set(s.defaultHidden())
s.widths.Set(map[string]float64{})
s.calculated.Set(nil)
s.summaries.Set(nil)
s.persistColumns()
}
func (s *AutoTableState) defaultHidden() map[string]bool {
out := map[string]bool{}
for i, c := range s.cols {
if c.HiddenByDefault {
out[s.columnKey(c, i)] = true
}
}
return out
}
// ---- persistence ----
type persistedColumns struct {
Order []string `json:"order,omitempty"`
Hidden map[string]bool `json:"hidden,omitempty"`
Widths map[string]float64 `json:"widths,omitempty"`
// Columns a user BUILT are worth keeping more than any of the above — losing a
// formula they wrote is losing work, not just a preference.
Calculated []UserCalculatedColumn `json:"calculated,omitempty"`
Summaries []UserSummaryRow `json:"summaries,omitempty"`
// CalcSeq keeps generated IDs unique across reloads, so a restored calc column
// and a newly added one cannot collide.
CalcSeq int `json:"calcSeq,omitempty"`
}
func (s *AutoTableState) storageKey() string {
if s.opts.Columns.StorageKey == "" {
return ""
}
return "autotable:" + s.opts.Columns.StorageKey
}
// LayoutSettled reports whether the user's saved layout has been applied. False means
// the table is still showing its skeleton.
func (s *AutoTableState) LayoutSettled() bool { return s.restored.Get() }
// pendingSkeleton stands in for the table until the saved layout has been applied.
//
// It is deliberately a SKELETON and not the real table dimmed or hidden: the whole
// point is that the user must not see column headers, orders or values that are about
// to change under them. A skeleton promises nothing, so it cannot lie.
//
// The bar widths vary a little so it reads as a table rather than a loading bar, and
// they are fixed rather than random — a random width would differ between the server's
// render and the client's, and hydration would have to correct every one of them.
func (s *AutoTableState) pendingSkeleton() *vdom.VNode {
widths := []string{"70%", "45%", "60%", "38%", "66%", "50%"}
bars := make([]*vdom.VNode, 0, len(widths)+1)
bars = append(bars, vdom.Div(
vdom.Attr("class", cx(atSkeleton, "h-6 mb-4")),
vdom.Attr("style", "width: 30%"),
))
for _, w := range widths {
bars = append(bars, vdom.Div(
vdom.Attr("class", atSkeleton),
vdom.Attr("style", "width: "+w),
))
}
return vdom.Div(
vdom.Attr("class", "min-w-0 w-full max-w-full"),
vdom.Attr("aria-busy", "true"),
vdom.Attr("aria-label", "Loading table"),
vdom.Div(
kids([]vdom.Mod{
vdom.Attr("class", cx(TBL_CONTAINER, "border border-line-strong p-4 flex flex-col gap-3")),
}, bars)...,
),
)
}
// LayoutJSON is the table's current personal layout — order, widths, hidden columns,
// and any calculated columns or summary rows the user built — as the JSON that gets
// persisted.
func (s *AutoTableState) LayoutJSON() string {
blob, err := json.Marshal(persistedColumns{
Order: s.order.Get(),
Hidden: s.hidden.Get(),
Widths: s.widths.Get(),
Calculated: s.calculated.Get(),
Summaries: s.summaries.Get(),
CalcSeq: s.calcSeq,
})
if err != nil {
return ""
}
return string(blob)
}
func (s *AutoTableState) persistColumns() {
key := s.storageKey()
if key == "" {
return
}
blob := s.LayoutJSON()
if blob == "" {
return // a table forgetting its layout is not worth failing over
}
wasmruntime.StorageSet(key, blob)
}
// RestoreLayout reads the user's saved layout and applies it, then marks the table
// as settled so it can be shown.
//
// It runs automatically on the client's first commit. Call it directly only to say
// "there is nothing to restore" — a server, or a test, that wants the table rendered
// rather than its skeleton.
//
// # Why this cannot run before the first render, and what that costs
//
// The layout lives in localStorage, which the SERVER cannot read. So the server
// renders the DECLARED table. If the client read storage before its first render, it
// would disagree with the HTML it is hydrating and adopt the wrong nodes — so it must
// render the declared table too, and can only apply the personal one afterwards.
//
// That gap is unavoidable, and with a multi-megabyte wasm it is not a frame, it is
// seconds. What IS avoidable is showing the WRONG table across it: see `restored`,
// which holds a skeleton in place until this has run. The user waits, but they never
// watch their columns rearrange themselves.
func (s *AutoTableState) RestoreLayout() {
defer s.restored.Set(true) // settled either way: nothing to restore is still settled
key := s.storageKey()
if key == "" {
return
}
raw, ok := wasmruntime.StorageGet(key)
if !ok {
return
}
var p persistedColumns
if json.Unmarshal([]byte(raw), &p) != nil {
wasmruntime.StorageRemove(key) // corrupt, or an older shape; drop it rather than half-apply it
return
}
if p.Order != nil {
s.order.Set(p.Order)
}
if p.Hidden != nil {
s.hidden.Set(p.Hidden)
}
if p.Widths != nil {
s.widths.Set(p.Widths)
}
// A user's own calculated columns REPLACE the declared ones rather than merging:
// the editor's list is what they last saw, and silently re-adding a column they
// deleted would be worse than losing one they added.
if p.Calculated != nil {
s.calculated.Set(p.Calculated)
}
if p.Summaries != nil {
s.summaries.Set(p.Summaries)
}
if p.CalcSeq > s.calcSeq {
s.calcSeq = p.CalcSeq
}
}
// ---- the column picker ----
// ColumnPicker renders the show/hide control. Columns that are not Toggleable are
// omitted: they cannot be hidden, so offering them would be a lie.
func (s *AutoTableState) ColumnPicker() *vdom.VNode {
if !s.opts.Columns.Toggleable {
return nil
}
var options []FormSelectOption
var selected []string
for i, c := range s.cols {
if !c.Toggleable {
continue
}
k := s.columnKey(c, i)
options = append(options, FormSelectOption{Value: k, Label: c.DisplayName})
if !s.hidden.Get()[k] {
selected = append(selected, k)
}
}
if len(options) == 0 {
return nil
}
total := len(options)
return s.multiSelect("__columns__").Render(FormMultiSelectProps{
Options: options,
Value: selected,
Placeholder: "Columns",
Searchable: true,
ShowSelectAll: true,
FieldWidth: "w-52",
// Not "5 items selected": this picker is about COLUMNS, and the count is only
// meaningful next to how many there are to choose from.
CollapsedLabel: func(n int) string {
return strconv.Itoa(n) + " of " + strconv.Itoa(total) + " columns"
},
OnChange: func(visible []string) {
show := map[string]bool{}
for _, k := range visible {
show[k] = true
}
next := map[string]bool{}
for _, o := range options {
if !show[o.Value] {
next[o.Value] = true
}
}
s.hidden.Set(next)
s.persistColumns()
},
})
}
// ResetColumnsButton restores the default layout.
// ResetMenu is the "Reset" control that sits at the bottom-left of the table, in the
// pagination bar. It opens UPWARD (it lives at the foot of the page) and offers one
// entry per thing the user can have customised, plus an Everything.
//
// Entries appear only when there is something to undo: offering "Reset column widths"
// to someone who has never dragged one is noise, and worse, it implies the table has
// state it does not have.
func (s *AutoTableState) ResetMenu() *vdom.VNode {
if s.resetMenu == nil {
s.resetMenu = NewMenu(MenuOptions{Placement: PlacementTopStart})
}
m := s.resetMenu
var items []*vdom.VNode
if len(s.order.Get()) > 0 {
items = append(items, m.Item(MenuItemProps{Icon: "grip-vertical", OnClick: s.ResetColumnOrder},
vdom.Text("Column order")))
}
if len(s.widths.Get()) > 0 {
items = append(items, m.Item(MenuItemProps{Icon: "arrow-right", OnClick: s.ResetColumnWidths},
vdom.Text("Column widths")))
}
if len(s.hidden.Get()) > 0 {
items = append(items, m.Item(MenuItemProps{Icon: "table-columns", OnClick: s.ResetColumnVisibility},
vdom.Text("Hidden columns")))
}
if len(s.Calculated()) > 0 {
items = append(items, m.Item(MenuItemProps{Icon: "calculator", OnClick: s.ResetCalculated},
vdom.Text("Calculated columns")))
}
if len(s.SummaryRows()) > 0 {
items = append(items, m.Item(MenuItemProps{Icon: "list-ol", OnClick: s.ResetSummaryRows},
vdom.Text("Summary rows")))
}
if len(items) == 0 {
items = append(items, vdom.Div(vdom.Attr("class", "px-3 py-2 text-sm text-ink-muted"),
vdom.Text("Nothing to reset"),
))
} else {
items = append(items,
MenuDivider(""),
m.Item(MenuItemProps{Icon: "trash", OnClick: s.ResetAll}, vdom.Text("Everything")),
)
}
return vdom.Div(vdom.Attr("class", "contents"),
m.TriggerFunc(MenuTriggerProps{Tag: "div"}, func(open bool) *vdom.VNode {
icon := "chevron-up"
if open {
icon = "chevron-down"
}
return Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Text: "Reset",
Class: "gap-1"}, Icon(icon, 12, ""))
}),
m.Content("", items...),
)
}
// columnHooks bundles the state the header needs to render drag handles, resize
// handles and explicit widths, without AutoTable itself owning any of it.
func (s *AutoTableState) columnHooks() *atColumnHooks {
c := s.opts.Columns
if !c.Draggable && !c.Resizable && len(s.widths.Get()) == 0 {
return nil
}
return &atColumnHooks{
key: s.columnKey,
ref: s.thRef,
width: s.ColumnWidth,
draggable: c.Draggable,
resizable: c.Resizable,
dragging: s.dragKey.Get(),
dropTarget: s.dropKey.Get(),
onDragStart: func(k string) { s.dragKey.Set(k) },
onDragOver: func(k string) { s.dropKey.Set(k) },
onDrop: s.MoveColumn,
onDragEnd: func() { s.dragKey.Set(""); s.dropKey.Set("") },
onResizeStart: s.beginResize,
}
}
// ==========================================================================
// Calculated columns and summary rows
// ==========================================================================
// Wiring the formula engine (autotable_formula.go) into the table: user-defined
// calculated columns become real columns, and summary rows become a .
//
// The subtlety here is WHICH rows a formula sees, and in what order. A calculated
// column can reference the whole column ({Revenue}), a range of it
// ({Revenue:1:ROW()} — a running total), or the current row's position (ROW()). So
// its value depends on the row's index in the FILTERED, SORTED result set — not on
// the page you happen to be looking at, and not on the unfiltered data. Filter the
// table and a running total re-runs; sort it and it re-runs again. That is what the
// user means by a running total, and it is why cells are rendered through CellAt
// (which gets the absolute index) rather than Cell.
// atWithFoot supplies the .
func atWithFoot(foot *vdom.VNode) AutoTableOption {
return func(c *atConfig) { c.foot = foot }
}
// atWithRowOffset tells the body where the current page starts within the filtered
// set, so CellAt sees true row positions.
func atWithRowOffset(n int) AutoTableOption {
return func(c *atConfig) { c.rowOffset = n }
}
// calcColumns turns each UserCalculatedColumn into an AutoTableColumn whose cell
// evaluates the formula for that row.
//
// values is the pre-evaluated column, aligned to the sorted+filtered rows — every
// cell is computed once per render rather than once per cell, which matters because
// an aggregate formula ({Revenue}) walks the whole column each time it is asked.
func (s *AutoTableState) calcColumns(ctx *CalcContext, values map[string][]float64) []AutoTableColumn {
out := make([]AutoTableColumn, 0, len(s.Calculated()))
for _, uc := range s.Calculated() {
calc := uc
vals := values[calc.ID]
out = append(out, AutoTableColumn{
Key: CalcRef(calc.ID),
DisplayName: calc.DisplayName,
DisplayPosition: calc.DisplayPosition,
Toggleable: true,
// Sortable under its _calc_ identifier; Process handles the sort
// specially, because the value depends on the row index and so cannot be
// read out of the row itself.
Sortable: true,
SortIdentifier: CalcRef(calc.ID),
CSV: true,
// CSVValueAt, not CSVValue: a calculated value is a function of the row's
// POSITION (ROW(), a running total), which an index-free callback cannot
// express. It would have exported every running total as the same number.
CSVValueAt: func(_ any, rowIndex int) string {
if rowIndex < 0 || rowIndex >= len(vals) {
return CalcEmptyValue
}
return FormatCalcResult(vals[rowIndex], calc.DataType, calc.Precision, "", "", "")
},
CellAt: func(_ any, rowIndex int) *vdom.VNode {
text := CalcEmptyValue
if rowIndex >= 0 && rowIndex < len(vals) {
text = FormatCalcResult(vals[rowIndex], calc.DataType, calc.Precision, "", "", "")
}
return vdom.Td(vdom.Attr("class", cx(POS_CLS[calc.DisplayPosition], "tabular-nums")),
vdom.Text(text),
)
},
})
}
return out
}
// ---- calculated columns and summary rows as live state ----
// Calculated is the current list of calculated columns. It is state, not a fixed
// option: the whole point of CalculatedColumnEditor is that a user builds these at
// runtime, the way they would in a spreadsheet.
func (s *AutoTableState) Calculated() []UserCalculatedColumn { return s.calculated.Get() }
// SummaryRows is the current list of footer rows.
func (s *AutoTableState) SummaryRows() []UserSummaryRow { return s.summaries.Get() }
// AddCalculated appends a calculated column (replacing one with the same ID, so it
// doubles as an update). An empty ID gets one.
func (s *AutoTableState) AddCalculated(uc UserCalculatedColumn) {
if uc.ID == "" {
s.calcSeq++
uc.ID = "calc" + strconv.Itoa(s.calcSeq)
}
next := make([]UserCalculatedColumn, 0, len(s.Calculated())+1)
replaced := false
for _, existing := range s.Calculated() {
if existing.ID == uc.ID {
next = append(next, uc)
replaced = true
} else {
next = append(next, existing)
}
}
if !replaced {
next = append(next, uc)
}
s.calculated.Set(next)
s.persistColumns()
}
// RemoveCalculated deletes a calculated column, and drops the sort if the table was
// ordered by it — leaving a sort pointing at a column that no longer exists would
// silently stop sorting.
func (s *AutoTableState) RemoveCalculated(id string) {
next := []UserCalculatedColumn{}
for _, uc := range s.Calculated() {
if uc.ID != id {
next = append(next, uc)
}
}
s.calculated.Set(next)
if s.orderBy.Get().Identifier == CalcRef(id) {
s.orderBy.Set(AutoTableOrderBy{})
}
s.persistColumns()
}
// AddSummaryRow appends (or replaces, by ID) a footer row.
func (s *AutoTableState) AddSummaryRow(sr UserSummaryRow) {
if sr.ID == "" {
s.calcSeq++
sr.ID = "sum" + strconv.Itoa(s.calcSeq)
}
next := make([]UserSummaryRow, 0, len(s.SummaryRows())+1)
replaced := false
for _, existing := range s.SummaryRows() {
if existing.ID == sr.ID {
next = append(next, sr)
replaced = true
} else {
next = append(next, existing)
}
}
if !replaced {
next = append(next, sr)
}
s.summaries.Set(next)
s.persistColumns()
}
// RemoveSummaryRow deletes a footer row.
func (s *AutoTableState) RemoveSummaryRow(id string) {
next := []UserSummaryRow{}
for _, sr := range s.SummaryRows() {
if sr.ID != id {
next = append(next, sr)
}
}
s.summaries.Set(next)
s.persistColumns()
}
// ExportColumns are the columns an export writes: the ones the user can actually
// see, in the order they put them in, plus any calculated columns.
//
// NOT the declared columns. Exporting those would write columns the user had hidden
// and ignore the order they dragged them into — the export would not match the table
// it came from.
func (s *AutoTableState) ExportColumns() []AutoTableColumn {
cols := s.VisibleColumns()
if len(s.Calculated()) == 0 {
return cols
}
// Evaluated against the filtered rows in their final order, which is exactly what
// the export walks.
ctx, values := s.evalCalcColumns(s.FilteredRows())
return append(cols, s.calcColumns(ctx, values)...)
}
// evalCalcColumns evaluates every calculated column against `rows`, in order.
func (s *AutoTableState) evalCalcColumns(rows []any) (*CalcContext, map[string][]float64) {
ctx := NewCalcContext(rows, s.cols, s.Calculated(), s.read)
values := make(map[string][]float64, len(s.Calculated()))
for _, uc := range s.Calculated() {
col := make([]float64, len(rows))
for i := range rows {
// The error is a diagnostic; the value is already NaN, which formats as
// CalcEmptyValue. A broken formula shows a dash, it does not break the table.
col[i], _ = ComputeCalculatedColumn(uc, ctx.ForRow(i))
}
values[uc.ID] = col
}
return ctx, values
}
// sortByCalc orders rows by a calculated column.
//
// It cannot go through SortRows: that reads the sort key out of the row, and a
// calculated value is not in the row — it is a function of the row's POSITION. So
// the values are evaluated against the pre-sort order, and the rows are permuted to
// match. (The values are then re-evaluated against the final order by Process, so a
// running total still counts down the screen. Sorting by a running total is
// therefore self-referential — as it is in the TSX. Sorting by a position-independent
// formula, which is the normal case, is exact.)
func sortByCalc(rows []any, values []float64, descending bool) []any {
idx := make([]int, len(rows))
for i := range idx {
idx[i] = i
}
stableSortInts(idx, func(a, b int) bool {
av, bv := values[a], values[b]
aNaN, bNaN := av != av, bv != bv
if aNaN != bNaN {
return bNaN // NaN (an unevaluable formula) sorts last, like an empty cell
}
if aNaN {
return false
}
if av == bv {
return false
}
if descending {
return av > bv
}
return av < bv
})
out := make([]any, len(rows))
for i, j := range idx {
out[i] = rows[j]
}
return out
}
// stableSortInts is an insertion sort — stable, and the row counts here are the ones
// a person is going to look at, not a million.
func stableSortInts(xs []int, less func(a, b int) bool) {
for i := 1; i < len(xs); i++ {
for j := i; j > 0 && less(xs[j], xs[j-1]); j-- {
xs[j], xs[j-1] = xs[j-1], xs[j]
}
}
}
// summaryFoot renders the : one row per UserSummaryRow, each evaluated once
// over the whole filtered set.
func (s *AutoTableState) summaryFoot(ctx *CalcContext, colCount int) *vdom.VNode {
if len(s.SummaryRows()) == 0 {
return nil
}
foot := vdom.Tfoot(vdom.Attr("class", AUTOTABLE_TFOOT))
for _, sr := range s.SummaryRows() {
foot.Children = append(foot.Children, vdom.Tr(vdom.Td(vdom.Attr("colspan", strconv.Itoa(max(colCount-1, 1))),
vdom.Attr("class", "text-right font-semibold"),
vdom.Text(sr.Label),
),
vdom.Td(vdom.Attr("class", "text-right font-semibold tabular-nums"),
vdom.Text(FormatSummaryRow(sr, ctx)),
),
))
}
return foot
}
// AUTOTABLE_TFOOT styles the summary footer.
const AUTOTABLE_TFOOT = "border-t-2 border-line-strong bg-surface-muted [&_td]:p-3"
// ---- the calculated-column editor ----
// The port of the TSX's AddCalcMenu / CalculatedColumnForm / SummaryRowForm /
// FormulaField.
//
// The model it edits (the part that is easy to get backwards):
//
// - A calculated COLUMN with a predefined function combines its operand columns
// ACROSS THE ROW: sum over [Revenue, Cost] is revenue + cost, per row. It does
// NOT aggregate a column down the table.
// - A SUMMARY ROW does the opposite: it aggregates ONE operand column DOWN the
// rows.
// - subtract and divide are BINARY and ORDERED — a − b, a ÷ b.
// - Operands are column KEYS (a column's SortIdentifier, or _calc_). Formulas
// name columns by their DISPLAY name instead: [Revenue] is this row's cell,
// {Revenue} is the whole column.
//
// The two are separate forms behind a chooser, as in the TSX, because they are
// different things — not one form with a "summary?" switch.
// isBinaryCalcFn reports whether a function takes exactly two ordered operands.
func isBinaryCalcFn(fn CalculatedFunction) bool {
return fn == CALC_FN_SUBTRACT || fn == CALC_FN_DIVIDE
}
// Tailwind for the editor, copied from AutoTable.tsx.
//
// The formula input is a single horizontally-scrolling line, so it is never taller
// than any other field. It is TRANSPARENT text with a visible caret, sitting on top
// of an overlay that renders the same text with syntax colouring — which is how you
// get highlighting in a plain |