Files
kjol/go/webui/autotable.go

6093 lines
198 KiB
Go
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Port of web/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-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, 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 <td>; nil renders an empty aligned cell
// CellAt is Cell with the row's index in the full filtered result set (not the
// page). Use it when the cell depends on where the row sits — a running total, a
// rank, anything a calculated column's ROW() can reach. Takes precedence over Cell.
CellAt func(row any, rowIndex int) *vdom.VNode
// Sorting. SortIdentifier names the field to sort on (use PositionalIdentifier(i)
// for a column with no natural field name). SortType picks the comparison —
// SortTypeNumeric makes "Item 2" sort before "Item 10"; SortTypeMoney parses
// "$1,234.50". SortValue overrides the whole lookup when the sort key is not a
// field at all (a computed total, a status rank).
Sortable bool
SortIdentifier string
SortType string
SortValue func(row any) any
// Export. CSV includes the column in exports; CSVValue produces its text (the
// rendered cell is a VNode, so exports cannot reuse it). A column with CSV set
// and no CSVValue falls back to the field named by SortIdentifier.
//
// CSVValueAt is CSVValue with the row's index in the exported set, for a value
// that depends on position (a calculated column's running total). It takes
// precedence over CSVValue.
CSV bool
CSVValue func(row any) string
CSVValueAt func(row any, rowIndex int) string
// Column visibility. Toggleable columns can be hidden by the user; a column with
// Toggleable false is pinned on. HiddenByDefault starts hidden.
Toggleable bool
HiddenByDefault bool
// Key identifies the column across reorder/resize/visibility persistence. It
// must be stable across renders — if empty, the column's index is used, which
// breaks the moment columns are reordered. Set it whenever those features are on.
Key string
}
// 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)
paginationShowAll bool
// Expandable rows. rowKey must be stable for a row across renders — it is what
// remembers which rows are open.
accordion bool
rowKey func(row any, idx int) string
isExpanded func(key string) bool
onToggleExpand func(key string)
accordionContent func(row any) *vdom.VNode
// highlight flags a row (a record the user came here to find).
highlight func(row any) bool
// columns carries drag/resize/width behavior, or nil when it is off.
columns *atColumnHooks
// rowOffset is the index of the page's first row within the full filtered set,
// so a CellAt sees the row's true position rather than its position on screen.
rowOffset int
// foot is the <tfoot> (summary rows), or nil.
foot *vdom.VNode
// reset is the Reset menu, shown at the bottom-left of the pagination bar.
reset *vdom.VNode
// Chrome around the table.
searchFields []*vdom.VNode
toolbarActions []*vdom.VNode
filtersToggle *vdom.VNode
filtersOpen bool
searchAside bool
above, below *vdom.VNode
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 }
}
// AutoTableWithPaginationShowAll adds an "All" entry to the page-size picker.
func AutoTableWithPaginationShowAll() AutoTableOption {
return func(c *atConfig) { c.paginationShowAll = true }
}
// AutoTableWithAccordion makes rows expandable: an extra toggle column appears,
// and clicking a row reveals content(row) in a full-width row beneath it.
func AutoTableWithAccordion(
rowKey func(row any, idx int) string,
isExpanded func(key string) bool,
onToggle func(key string),
content func(row any) *vdom.VNode,
) AutoTableOption {
return func(c *atConfig) {
c.accordion = true
c.rowKey = rowKey
c.isExpanded = isExpanded
c.onToggleExpand = onToggle
c.accordionContent = content
}
}
// AutoTableWithHighlight flags matching rows.
func AutoTableWithHighlight(match func(row any) bool) AutoTableOption {
return func(c *atConfig) { c.highlight = match }
}
// AutoTableWithSearchFields puts filter controls in the toolbar above the table.
func AutoTableWithSearchFields(fields ...*vdom.VNode) AutoTableOption {
return func(c *atConfig) { c.searchFields = fields }
}
// AutoTableWithToolbarActions puts buttons (export, add, …) on the toolbar's right.
func AutoTableWithToolbarActions(actions ...*vdom.VNode) AutoTableOption {
return func(c *atConfig) { c.toolbarActions = actions }
}
// AutoTableWithFiltersToggle supplies the narrow-screen Filters button and whether
// the panel is currently open. Below the `sm` breakpoint the filter fields are
// hidden until it is.
func AutoTableWithFiltersToggle(toggle *vdom.VNode, open bool) AutoTableOption {
return func(c *atConfig) {
c.filtersToggle = toggle
c.filtersOpen = open
}
}
// AutoTableWithSearchAside moves the filters into a card beside the table instead
// of a strip above it.
func AutoTableWithSearchAside() AutoTableOption {
return func(c *atConfig) { c.searchAside = true }
}
// AutoTableWithAbove / AutoTableWithBelow inject arbitrary content around the table.
func AutoTableWithAbove(n *vdom.VNode) AutoTableOption {
return func(c *atConfig) { c.above = n }
}
func AutoTableWithBelow(n *vdom.VNode) AutoTableOption {
return func(c *atConfig) { c.below = n }
}
// 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.Table(vdom.Attr("class", tableCls),
atRenderHead(cols, cfg),
atRenderBody(cols, rows, cfg),
)
if cfg.foot != nil {
table.Children = append(table.Children, cfg.foot)
}
container := vdom.Div(vdom.Attr("class", containerCls),
vdom.Div(vdom.Attr("class", TBL_WRAPPER), table),
)
if footer := atRenderPagination(cfg); footer != nil {
container.Children = append(container.Children, footer)
}
// The table plus its chrome. With searchAside the filters become a card to the
// left; otherwise they are a strip above.
body := []*vdom.VNode{}
if cfg.above != nil {
body = append(body, cfg.above)
}
if cfg.searchAside && len(cfg.searchFields) > 0 {
aside := vdom.Div(kids([]vdom.Mod{vdom.Attr("class", AUTOTABLE_ASIDE)}, cfg.searchFields)...)
body = append(body, vdom.Div(vdom.Attr("class", "flex flex-col sm:flex-row gap-4"),
aside,
vdom.Div(vdom.Attr("class", "min-w-0 grow"), container),
))
} else {
if toolbar := atRenderToolbar(cfg); toolbar != nil {
body = append(body, toolbar)
}
body = append(body, container)
}
if cfg.below != nil {
body = append(body, cfg.below)
}
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx("min-w-0 w-full max-w-full", cfg.class))}, body)...,
)
}
// atRenderToolbar builds the filter strip above the table: the search fields, the
// narrow-screen Filters toggle, and any caller-supplied actions.
//
// Below the `sm` breakpoint the fields collapse behind the toggle — which is why
// the toggle carries a badge with the active-filter count. A user who cannot see
// the fields still needs to know the table is filtered.
func atRenderToolbar(cfg *atConfig) *vdom.VNode {
if len(cfg.searchFields) == 0 && len(cfg.toolbarActions) == 0 {
return nil
}
left := []*vdom.VNode{}
if cfg.filtersToggle != nil {
left = append(left, cfg.filtersToggle)
}
if len(cfg.searchFields) > 0 {
visibility := "hidden sm:flex"
if cfg.filtersToggle == nil || cfg.filtersOpen {
visibility = "flex"
}
left = append(left, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", cx(AUTOTABLE_SEARCH_FIELDS, visibility))}, cfg.searchFields)...,
))
}
row := vdom.Div(vdom.Attr("class", AUTOTABLE_TOOLBAR),
vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-wrap items-end gap-2")}, left)...),
)
if len(cfg.toolbarActions) > 0 {
row.Children = append(row.Children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex items-center gap-2")}, cfg.toolbarActions)...,
))
}
return row
}
// atTotalColumns is the real column count, including the accordion toggle — what a
// full-width expanded row has to span.
func atTotalColumns(cols []AutoTableColumn, cfg *atConfig) int {
n := len(cols)
if n == 0 {
n = 1
}
if cfg.accordion {
n++
}
return n
}
// atRenderHead builds the <thead> with one header <tr>.
func atRenderHead(cols []AutoTableColumn, cfg *atConfig) *vdom.VNode {
headerColor := HEADER_COLOR_CLS[cfg.color]
headerPadding := HEADER_PADDING_CLS[cfg.size]
tr := vdom.Tr()
if cfg.accordion {
// A spacer above the expand/collapse toggle column.
tr.Children = append(tr.Children, vdom.Th(vdom.Attr("class", cx(headerPadding, headerColor, AUTOTABLE_ACCORDION_CELL))))
}
if len(cols) == 0 {
tr.Children = append(tr.Children,
vdom.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.Thead(vdom.Attr("class", atTheadCls), tr)
}
// atColumnHooks is how AutoTableState injects drag/resize/width behavior into the
// header without AutoTable itself owning any of that state. Nil when column
// management is off, which is why every use below is guarded.
type atColumnHooks struct {
key func(col AutoTableColumn, i int) string
ref func(key string) *vdom.Ref
width func(key string) float64
draggable bool
resizable bool
dragging string // the column currently being dragged
dropTarget string // the column it is hovering over
onDragStart func(key string)
onDragOver func(key string)
onDrop func(from, to string)
onDragEnd func()
onResizeStart func(key string, clientX int)
}
func atWithColumnHooks(h *atColumnHooks) AutoTableOption {
return func(c *atConfig) { c.columns = h }
}
// Tailwind for the drag/resize affordances, from AutoTable.tsx.
const (
RESIZE_HANDLE_CLS = "absolute top-0 right-0 h-full w-1 cursor-col-resize select-none hover:bg-sky-500/50"
DRAG_GRIP_CLS = "cursor-grab opacity-0 group-hover/th:opacity-50"
DRAGGING_TH_CLS = "scale-95 opacity-60"
DROP_TARGET_TH_CLS = "outline-2 outline-sky-500"
)
// atRenderHeaderCell builds one <th>, including its drag grip and resize handle
// when column management is on.
func atRenderHeaderCell(col AutoTableColumn, displayIdx int, cfg *atConfig, headerColor, headerPadding string) *vdom.VNode {
pos := col.DisplayPosition
posCls := POS_CLS[pos]
hooks := cfg.columns
key := ""
if hooks != nil {
key = hooks.key(col, displayIdx)
}
thCls := cx(headerPadding, headerColor, posCls)
// A user-dragged width wins over the declared Tailwind class: the class would
// fight the inline width, and the user's intent is the more specific one.
width := 0.0
if hooks != nil {
width = hooks.width(key)
}
if width == 0 {
thCls = cx(thCls, col.WidthClass)
}
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])
}
if hooks != nil && (hooks.draggable || hooks.resizable) {
thCls = cx(thCls, "relative group/th")
}
if hooks != nil && hooks.dragging == key && key != "" {
thCls = cx(thCls, DRAGGING_TH_CLS)
}
if hooks != nil && hooks.dropTarget == key && hooks.dragging != key && key != "" {
thCls = cx(thCls, DROP_TARGET_TH_CLS)
}
thCls = cx(thCls, col.HeaderClasses)
mods := []vdom.Mod{vdom.Attr("class", thCls)}
if hooks != nil {
mods = append(mods, vdom.WithRef(hooks.ref(key)))
if width > 0 {
mods = append(mods, vdom.Attr("style", "width:"+px(width)))
}
}
if col.Sortable {
sortID := col.SortIdentifier
if sortID != "" && cfg.onSort != nil {
mods = append(mods, vdom.On(vdom.EVENT_CLICK, func() { cfg.onSort(sortID) }))
}
}
if hooks != nil && hooks.draggable && key != "" {
mods = append(mods, atDragMods(key, hooks)...)
}
// Inner content: optional grip, label (grows), sort caret.
inner := vdom.Div(vdom.Attr("class", cx(HEADER_INNER_BASE, HEADER_INNER_POS[pos])))
if hooks != nil && hooks.draggable {
inner.Children = append(inner.Children,
vdom.Span(vdom.Attr("class", DRAG_GRIP_CLS), Icon("grip-vertical", 12, "")))
}
inner.Children = append(inner.Children,
vdom.Div(vdom.Attr("class", cx("grow text-sm", HEADER_TEXT_CLS[cfg.color])), vdom.Text(col.DisplayName)))
if col.Sortable {
iconWrap := vdom.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)
}
mods = append(mods, vdom.Div(vdom.Attr("class", HEADER_CONTENT), inner))
if hooks != nil && hooks.resizable && key != "" {
mods = append(mods, atResizeHandle(key, hooks))
}
return vdom.Th(mods...)
}
// atDragMods wires HTML5 drag-and-drop on a header.
//
// The dragover handler MUST call preventDefault: the browser's default is to reject
// the drop, and without it the drop event never fires at all. That is the classic
// HTML5 DnD footgun.
func atDragMods(key string, h *atColumnHooks) []vdom.Mod {
return []vdom.Mod{
vdom.Attr("draggable", "true"),
vdom.OnEvent(vdom.EVENT_DRAGSTART, func(e vdom.Event) {
e.SetData("text/plain", key)
h.onDragStart(key)
}),
vdom.OnEvent(vdom.EVENT_DRAGOVER, func(e vdom.Event) {
e.PreventDefault() // without this the drop never fires
h.onDragOver(key)
}),
vdom.OnEvent(vdom.EVENT_DROP, func(e vdom.Event) {
e.PreventDefault()
from := e.GetData("text/plain")
h.onDrop(from, key)
h.onDragEnd()
}),
vdom.OnEvent(vdom.EVENT_DRAGEND, func(vdom.Event) { h.onDragEnd() }),
}
}
// atResizeHandle is the grab strip on a header's right edge.
//
// It stops the mousedown propagating, or the click would also fire the header's
// sort handler — you would re-sort the table every time you resized a column.
func atResizeHandle(key string, h *atColumnHooks) *vdom.VNode {
return vdom.Div(vdom.Attr("class", RESIZE_HANDLE_CLS),
// A resize drag must not also start a column drag.
vdom.Attr("draggable", "false"),
vdom.OnEvent(vdom.EVENT_MOUSEDOWN, func(e vdom.Event) {
e.PreventDefault()
e.StopPropagation()
h.onResizeStart(key, e.ClientX())
}),
)
}
// atRenderBody builds the <tbody> 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 := 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-neutral-100"
}
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-neutral-100")
}
rowCls = cx(rowCls, hoverCls)
if cfg.borderX && !isLast {
rowCls = cx(rowCls, "border-b border-neutral-300")
}
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 <td>,
// 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_<n>` 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_<n>` 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_<n>` 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
// openSignals memoizes per-dropdown open state (see openSignal).
openSignals map[string]*vdom.Signal[bool]
// 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 <tfoot> 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})
}
open := s.openSignal(identifier)
return vdom.Div(vdom.Attr("class", AUTOTABLE_SEARCH_FIELD),
FormMultiSelect(FormMultiSelectProps{
Options: options,
Value: s.SearchValues(identifier),
Placeholder: pick(placeholder, "Any"),
ShowSelectAll: true,
Open: open.Get(),
OnToggle: func() { open.Set(!open.Get()) },
OnChange: func(vs []string) { s.SetSearchValues(identifier, vs, true) },
}),
)
}
// openSignal memoizes the open state of a dropdown, keyed by identifier. It has to
// be memoized: a signal created fresh inside a render would reset to closed on
// every render, so the dropdown could never stay open.
func (s *AutoTableState) openSignal(key string) *vdom.Signal[bool] {
if s.openSignals == nil {
s.openSignals = map[string]*vdom.Signal[bool]{}
}
sig, ok := s.openSignals[key]
if !ok {
sig = vdom.NewSignal(false)
s.openSignals[key] = sig
}
return sig
}
// 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-neutral-300 bg-white px-3 py-2 text-sm"
AUTOTABLE_FILTERS_TOGGLE = "sm:hidden inline-flex items-center gap-2 rounded-default border border-neutral-300 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-xs 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-neutral-300 bg-white p-3"
AUTOTABLE_ACCORDION_CELL = "w-10 text-center"
AUTOTABLE_ACCORDION_ROW = "bg-neutral-50"
AUTOTABLE_HIGHLIGHT_ROW = "bg-amber-100! 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-neutral-300 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
}
open := s.openSignal("__columns__")
return FormMultiSelect(FormMultiSelectProps{
Options: options,
Value: selected,
Placeholder: "Columns",
Searchable: true,
ShowSelectAll: true,
FieldWidth: "w-52",
Open: open.Get(),
OnToggle: func() { open.Set(!open.Get()) },
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-neutral-500"),
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 <tfoot>.
//
// 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 <tfoot>.
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_<id> 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 <tfoot>: 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-neutral-300 bg-neutral-50 [&_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_<id>). 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 <textarea> at all. Both share FORMULA_EDIT_BASE so
// their metrics line up to the pixel; if they ever drift, the caret stops landing on
// the glyph it appears to be on.
const (
CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-neutral-50 text-black border border-neutral-300 hover:bg-neutral-100 py-1 px-3"
FORMULA_EDIT_BASE = "block w-full h-[30px] font-mono text-sm p-1 rounded-default box-border whitespace-pre"
FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-neutral-800"
FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-neutral-800 resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-neutral-300 focus:border-sky-500 outline-hidden"
formulaPlaceholder = `<span class="text-neutral-400">e.g. [Revenue] / SUM({Revenue}) * 100</span>`
calcMenuTrigger = "inline-flex items-center gap-1 rounded-default border border-neutral-300 bg-neutral-50 px-2 py-1 text-xs leading-none text-neutral-700 cursor-pointer hover:bg-neutral-100"
calcChooserItem = "w-full text-left px-2 py-2 rounded-default hover:bg-neutral-100 cursor-pointer border-0 bg-transparent flex items-start gap-2.5"
)
// HighlightFormula renders a formula as HTML with syntax colouring: cell refs
// [Revenue], column refs {Revenue}, numbers, function names, constants, operators.
//
// Purely visual, and deliberately LEXICAL rather than a real parse — it has to
// colour a half-typed formula that does not compile yet, which is exactly when the
// colours are worth the most.
func HighlightFormula(src string) string {
var b strings.Builder
i := 0
for i < len(src) {
c := src[i]
switch {
// [Cell] — this row's value.
case c == '[':
j := strings.IndexByte(src[i:], ']')
end := len(src)
if j >= 0 {
end = i + j + 1
}
b.WriteString(`<span class="text-sky-600">`)
b.WriteString(html.EscapeString(src[i:end]))
b.WriteString(`</span>`)
i = end
// {Column} — the whole column. Nested braces so {Col:1:ROW()} stays one span.
case c == '{':
depth, j := 1, i+1
for j < len(src) && depth > 0 {
switch src[j] {
case '{':
depth++
case '}':
depth--
}
j++
}
b.WriteString(`<span class="text-violet-600">`)
b.WriteString(html.EscapeString(src[i:j]))
b.WriteString(`</span>`)
i = j
case isDigit(c) || (c == '.' && i+1 < len(src) && isDigit(src[i+1])):
j := i + 1
for j < len(src) && (isDigit(src[j]) || src[j] == '.') {
j++
}
b.WriteString(`<span class="text-amber-600">`)
b.WriteString(html.EscapeString(src[i:j]))
b.WriteString(`</span>`)
i = j
case isFormulaAlpha(c):
j := i + 1
for j < len(src) && (isFormulaAlpha(src[j]) || isDigit(src[j])) {
j++
}
word := src[i:j]
// A name followed by "(" is a call; otherwise it may be a constant.
k := j
for k < len(src) && src[k] == ' ' {
k++
}
switch {
case k < len(src) && src[k] == '(':
b.WriteString(`<span class="text-emerald-700 font-semibold">`)
b.WriteString(html.EscapeString(word))
b.WriteString(`</span>`)
case isFormulaConstant(word):
b.WriteString(`<span class="text-amber-600">`)
b.WriteString(html.EscapeString(word))
b.WriteString(`</span>`)
default:
b.WriteString(html.EscapeString(word))
}
i = j
case strings.IndexByte("+-*/^%=<>(),:", c) >= 0:
b.WriteString(`<span class="text-neutral-400">`)
b.WriteString(html.EscapeString(string(c)))
b.WriteString(`</span>`)
i++
default:
b.WriteString(html.EscapeString(string(c)))
i++
}
}
return b.String()
}
// isFormulaConstant reports whether a bare word is one of PI, E, TAU, … — matched
// case-insensitively, as the evaluator does.
func isFormulaConstant(word string) bool {
_, ok := FormulaConstants[strings.ToUpper(word)]
return ok
}
// calcEditor is the editor's state. It lives on the AutoTableState (memoized) rather
// than being rebuilt per render, or every keystroke would reset the field being typed
// into.
type calcEditor struct {
pop *Popover
// view is the chooser's state: "menu" (pick what to add, and see what exists),
// "column", or "summary". The two forms are separate things, so they are separate
// views rather than one form with a switch on it.
view *vdom.Signal[string]
editingID *vdom.Signal[string] // "" = adding
advanced *vdom.Signal[bool] // write a formula instead of picking a function
name *vdom.Signal[string]
fn *vdom.Signal[string]
operands *vdom.Signal[[]string] // column KEYS, ordered (order matters for binary fns)
formula *vdom.Signal[string]
dataType *vdom.Signal[string]
precision *vdom.Signal[string]
position *vdom.Signal[int]
errorMsg *vdom.Signal[string]
opOpen *vdom.Signal[bool]
colMenu *Menu
fnMenu *Menu
constMenu *Menu
// The textarea and the highlight overlay behind it. Both are needed: the caret
// insert writes into the textarea, and the overlay's scroll has to follow it.
formulaRef *vdom.Ref
overlayRef *vdom.Ref
}
func (s *AutoTableState) calcEditorState() *calcEditor {
if s.editor != nil {
return s.editor
}
e := &calcEditor{
view: vdom.NewSignal("menu"),
editingID: vdom.NewSignal(""),
advanced: vdom.NewSignal(false),
name: vdom.NewSignal(""),
fn: vdom.NewSignal(string(CALC_FN_SUM)),
operands: vdom.NewSignal([]string{}),
formula: vdom.NewSignal(""),
dataType: vdom.NewSignal(string(CALC_TYPE_NUMBER)),
precision: vdom.NewSignal(""),
position: vdom.NewSignal(int(COL_POS_RIGHT)),
errorMsg: vdom.NewSignal(""),
opOpen: vdom.NewSignal(false),
formulaRef: vdom.NewRef(),
overlayRef: vdom.NewRef(),
// Standalone: these menus live INSIDE the editor's popover, and without it the
// single-open manager would read them as a rival panel and close their own
// parent as they opened.
colMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
fnMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
constMenu: NewMenu(MenuOptions{Placement: PlacementBottomStart, Standalone: true}),
}
e.pop = NewPopover(PopoverProps{
Placement: PlacementBottomEnd,
// Closing the popover abandons the draft — reopening should start clean rather
// than resume a half-written formula the user walked away from.
OnOpenChange: func(open bool) {
if !open {
e.reset()
}
},
})
s.editor = e
return e
}
func (e *calcEditor) reset() {
e.view.Set("menu")
e.editingID.Set("")
e.advanced.Set(false)
e.name.Set("")
e.fn.Set(string(CALC_FN_SUM))
e.operands.Set(nil)
e.formula.Set("")
e.dataType.Set(string(CALC_TYPE_NUMBER))
e.precision.Set("")
e.position.Set(int(COL_POS_RIGHT))
e.errorMsg.Set("")
}
func (e *calcEditor) currentFn() CalculatedFunction { return CalculatedFunction(e.fn.Get()) }
func (e *calcEditor) isSummary() bool { return e.view.Get() == "summary" }
// setOperand writes the operand at index i, growing the list as needed — how the two
// ordered boxes of a binary function are bound.
func (e *calcEditor) setOperand(i int, v string) {
ops := append([]string{}, e.operands.Get()...)
for len(ops) <= i {
ops = append(ops, "")
}
ops[i] = v
e.operands.Set(ops)
}
func (e *calcEditor) operandAt(i int) string {
ops := e.operands.Get()
if i < len(ops) {
return ops[i]
}
return ""
}
// calcFunctions are the predefined functions, in the order the TSX lists them.
var calcFunctions = []struct {
Value CalculatedFunction
Label string
}{
{CALC_FN_SUM, "Sum"},
{CALC_FN_SUBTRACT, "Subtract"},
{CALC_FN_MULTIPLY, "Multiply"},
{CALC_FN_DIVIDE, "Divide"},
{CALC_FN_AVERAGE, "Average"},
{CALC_FN_MEDIAN, "Median"},
{CALC_FN_MODE, "Mode"},
{CALC_FN_MIN, "Min"},
{CALC_FN_MAX, "Max"},
{CALC_FN_COUNT, "Count"},
}
var calcDataTypes = []struct {
Value CalculatedDataType
Label string
}{
{CALC_TYPE_NUMBER, "Number"},
{CALC_TYPE_INTEGER, "Integer"},
{CALC_TYPE_DECIMAL, "Decimal"},
{CALC_TYPE_MONEY, "Money"},
{CALC_TYPE_PERCENT, "Percent"},
{CALC_TYPE_PLAIN, "Plain"},
}
// FormulaFunctionNames are the functions a formula may call, for the insert menu.
var FormulaFunctionNames = []string{
"SUM", "AVERAGE", "MEDIAN", "MODE", "MIN", "MAX", "COUNT",
"ABS", "ROUND", "FLOOR", "CEILING", "SQRT", "POWER", "MOD", "EXP", "LN", "LOG",
"SIN", "COS", "TAN", "ASIN", "ACOS", "ATAN", "ATAN2", "SINH", "COSH", "TANH",
"RADIANS", "DEGREES", "IF", "AND", "OR", "NOT", "ROW",
}
// operandOption is one entry in the operand pickers and the column insert menu: the
// KEY an operand uses, and the DISPLAY name a formula uses.
type operandOption struct {
Key string // SortIdentifier, or _calc_<id>
Label string // DisplayName
}
// operandOptions lists the columns an operand may reference — data columns with a
// sort identifier, plus every OTHER calculated column. The one being edited is
// excluded: a column referencing itself is a cycle, and the engine would (rightly)
// refuse to evaluate it.
func (s *AutoTableState) operandOptions(editingID string) []operandOption {
var out []operandOption
for _, c := range s.cols {
if c.SortIdentifier == "" || c.DisplayName == "" {
continue // nothing to reference it by
}
out = append(out, operandOption{Key: c.SortIdentifier, Label: c.DisplayName})
}
for _, uc := range s.Calculated() {
if uc.ID == editingID {
continue // no self-reference
}
out = append(out, operandOption{Key: CalcRef(uc.ID), Label: uc.DisplayName})
}
return out
}
// CalculatedColumnEditor renders the control for building calculated columns and
// summary rows at runtime.
//
// It opens on a chooser — what already exists (with edit and delete), and the two
// things you can add — because a column and a footer row are different objects, not
// one object with a flag. Picking either swaps the popover to that form.
func (s *AutoTableState) CalculatedColumnEditor() *vdom.VNode {
e := s.calcEditorState()
var body *vdom.VNode
switch e.view.Get() {
case "column":
body = s.calcForm(e, false)
case "summary":
body = s.calcForm(e, true)
default:
body = s.calcChooser(e)
}
return vdom.Div(vdom.Attr("class", "contents"),
e.pop.Trigger(PopoverTriggerProps{Class: CALC_ADD_TRIGGER_CLS},
Icon("calculator", 16, ""), vdom.Text("Calculated"),
),
e.pop.Content(PopoverContentProps{Class: "w-[26rem] max-h-[34rem] overflow-y-auto"}, body),
)
}
// calcChooser: what exists, and the two things you can add.
func (s *AutoTableState) calcChooser(e *calcEditor) *vdom.VNode {
children := []*vdom.VNode{}
// What already exists.
existing := []*vdom.VNode{}
for _, uc := range s.Calculated() {
calc := uc
existing = append(existing, calcListRow(calc.DisplayName, s.calcDescribe(calc.Fn, calc.Formula, calc.Operands),
func() { s.loadColumn(e, calc) },
func() { s.RemoveCalculated(calc.ID) },
))
}
for _, sr := range s.SummaryRows() {
row := sr
existing = append(existing, calcListRow(row.Label+" (footer)", s.calcDescribe(row.Fn, row.Formula, row.Operands),
func() { s.loadSummary(e, row) },
func() { s.RemoveSummaryRow(row.ID) },
))
}
if len(existing) > 0 {
children = append(children, vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1 border-b border-neutral-200 pb-2 mb-1")}, existing)...))
}
children = append(children,
calcChooserButton("table-columns", "Calculated column", "A new column computed for each row",
func() { e.view.Set("column") }),
calcChooserButton("list-ol", "Summary row", "A total or aggregate shown in the footer",
func() { e.view.Set("summary") }),
)
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-0.5 p-2")}, children)...)
}
func calcChooserButton(icon, title, subtitle string, onClick func()) *vdom.VNode {
return vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", calcChooserItem),
vdom.On(vdom.EVENT_CLICK, onClick),
Icon(icon, 16, "mt-0.5 text-neutral-500"),
vdom.Span(vdom.Attr("class", "flex flex-col"),
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
vdom.Span(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(subtitle)),
),
)
}
func calcListRow(title, subtitle string, onEdit, onDelete func()) *vdom.VNode {
return vdom.Div(vdom.Attr("class", "flex items-center gap-2 rounded-default px-2 py-1 hover:bg-neutral-50"),
vdom.Div(vdom.Attr("class", "min-w-0 grow"),
vdom.Div(vdom.Attr("class", "truncate text-sm font-medium text-neutral-800"), vdom.Text(title)),
vdom.Div(vdom.Attr("class", "truncate font-mono text-xs text-neutral-500"), vdom.Text(subtitle)),
),
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "pencil", Title: "Edit", OnClick: onEdit}),
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Icon: "trash", Title: "Delete", OnClick: onDelete}),
)
}
// calcDescribe summarises a definition in the table's own vocabulary: operands are
// stored as keys, so they are rendered back as display names.
func (s *AutoTableState) calcDescribe(fn CalculatedFunction, formula string, operands []string) string {
if fn == CALC_FN_CUSTOM || fn == "" {
return formula
}
labels := make([]string, 0, len(operands))
for _, key := range operands {
labels = append(labels, s.operandLabel(key))
}
return string(fn) + "(" + strings.Join(labels, ", ") + ")"
}
func (s *AutoTableState) operandLabel(key string) string {
for _, o := range s.operandOptions("") {
if o.Key == key {
return o.Label
}
}
return key
}
func (s *AutoTableState) loadColumn(e *calcEditor, uc UserCalculatedColumn) {
e.view.Set("column")
e.editingID.Set(uc.ID)
e.advanced.Set(uc.Fn == CALC_FN_CUSTOM)
e.name.Set(uc.DisplayName)
e.fn.Set(basicFnOf(uc.Fn))
e.operands.Set(append([]string{}, uc.Operands...))
e.formula.Set(uc.Formula)
e.dataType.Set(string(pickCalcType(uc.DataType)))
e.precision.Set(precisionText(uc.Precision))
e.position.Set(int(uc.DisplayPosition))
e.errorMsg.Set("")
}
func (s *AutoTableState) loadSummary(e *calcEditor, sr UserSummaryRow) {
e.view.Set("summary")
e.editingID.Set(sr.ID)
e.advanced.Set(sr.Fn == CALC_FN_CUSTOM)
e.name.Set(sr.Label)
e.fn.Set(basicFnOf(sr.Fn))
e.operands.Set(append([]string{}, sr.Operands...))
e.formula.Set(sr.Formula)
e.dataType.Set(string(pickCalcType(sr.DataType)))
e.precision.Set(precisionText(sr.Precision))
e.errorMsg.Set("")
}
// basicFnOf is the function the Basic tab should show. A custom definition has no
// basic function, so the tab falls back to a sensible default rather than an empty
// select — switching to Basic then means "start over as an aggregation".
func basicFnOf(fn CalculatedFunction) string {
if fn == CALC_FN_CUSTOM || fn == "" {
return string(CALC_FN_SUM)
}
return string(fn)
}
func pickCalcType(t CalculatedDataType) CalculatedDataType {
if t == "" {
return CALC_TYPE_NUMBER
}
return t
}
func precisionText(p *int) string {
if p == nil {
return ""
}
return strconv.Itoa(*p)
}
// calcForm is the column form or the summary-row form — the same shape, but the
// operand model differs (see calcBasicEditor), so `summary` runs right through it.
func (s *AutoTableState) calcForm(e *calcEditor, summary bool) *vdom.VNode {
editing := e.editingID.Get() != ""
title, nameLabel := "Calculated column", "Column name"
if summary {
title, nameLabel = "Summary row", "Label"
}
if editing {
title = "Edit " + strings.ToLower(title)
}
header := vdom.Div(vdom.Attr("class", "flex items-center gap-2 border-b border-neutral-200 px-3 py-2"),
vdom.Button(vdom.Attr("type", "button"),
vdom.Attr("class", "shrink-0 cursor-pointer rounded-default p-1 text-neutral-500 hover:bg-neutral-100 border-0 bg-transparent"),
vdom.Attr("aria-label", "Back"),
vdom.On(vdom.EVENT_CLICK, e.reset),
Icon("chevron-left", 14, ""),
),
vdom.Span(vdom.Attr("class", "text-sm font-medium text-neutral-800"), vdom.Text(title)),
// Basic vs Advanced.
vdom.Div(vdom.Attr("class", "ml-auto"),
SegmentedButtons([]SegmentedButtonOption{
{Value: "basic", Label: "Basic"},
{Value: "advanced", Label: "Advanced"},
}, calcModeValue(e.advanced.Get()),
func(v string) { e.advanced.Set(v == "advanced") }, true, "w-40"),
),
)
fields := []*vdom.VNode{
calcField(nameLabel, FormInput(FormInputProps{
Value: e.name.Get(),
Placeholder: "Annual salary",
Small: true,
OnInput: func(v string) { e.name.Set(v) },
})),
}
if e.advanced.Get() {
fields = append(fields, s.calcAdvancedEditor(e, summary))
} else {
fields = append(fields, s.calcBasicEditor(e, summary))
}
fields = append(fields, vdom.Div(vdom.Attr("class", "grid grid-cols-2 gap-2"),
calcField("Format", FormSelect(FormSelectProps{
Value: e.dataType.Get(), Small: true,
OnChange: func(v string) { e.dataType.Set(v) },
}, calcTypeOptions()...)),
calcField("Decimals", FormInput(FormInputProps{
Value: e.precision.Get(), Type: "number", Placeholder: "auto", Small: true,
OnInput: func(v string) { e.precision.Set(v) },
})),
))
// A footer row has no column of its own to align.
if !summary {
fields = append(fields, calcField("Align", FormSelect(FormSelectProps{
Value: strconv.Itoa(e.position.Get()), Small: true,
OnChange: func(v string) {
if n, err := strconv.Atoi(v); err == nil {
e.position.Set(n)
}
},
},
FormOption(strconv.Itoa(int(COL_POS_LEFT)), "Left", false),
FormOption(strconv.Itoa(int(COL_POS_CENTER)), "Center", false),
FormOption(strconv.Itoa(int(COL_POS_RIGHT)), "Right", false),
)))
}
if msg := e.errorMsg.Get(); msg != "" {
fields = append(fields, vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(msg)))
}
saveLabel := "Add"
if editing {
saveLabel = "Save"
}
fields = append(fields, vdom.Div(vdom.Attr("class", "flex items-center gap-2 pt-1"),
Button(ButtonProps{Color: ButtonPrimary, Small: true, Text: saveLabel,
OnClick: func() { s.saveCalc(e, summary) }}),
Button(ButtonProps{Color: ButtonLightNeutral, Small: true, Text: "Cancel", OnClick: e.reset}),
))
return vdom.Div(vdom.Attr("class", "flex flex-col"),
header,
vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-2 p-3")}, fields)...),
)
}
func calcModeValue(advanced bool) string {
if advanced {
return "advanced"
}
return "basic"
}
func calcTypeOptions() []*vdom.VNode {
out := make([]*vdom.VNode, 0, len(calcDataTypes))
for _, dt := range calcDataTypes {
out = append(out, FormOption(string(dt.Value), dt.Label, false))
}
return out
}
// calcBasicEditor: a function, and the columns it applies to.
//
// The shape of the operand picker follows from what the function MEANS:
//
// - a footer row aggregates ONE column down the table -> one picker
// - subtract / divide are binary and ORDERED -> two pickers, in order
// - everything else combines N columns across the row -> a multi-select
func (s *AutoTableState) calcBasicEditor(e *calcEditor, summary bool) *vdom.VNode {
opts := s.operandOptions(e.editingID.Get())
selectOpts := make([]*vdom.VNode, 0, len(opts)+1)
selectOpts = append(selectOpts, FormOption("", "Choose a column…", false))
for _, o := range opts {
selectOpts = append(selectOpts, FormOption(o.Key, o.Label, false))
}
fnOpts := make([]*vdom.VNode, 0, len(calcFunctions))
for _, f := range calcFunctions {
fnOpts = append(fnOpts, FormOption(string(f.Value), f.Label, false))
}
children := []*vdom.VNode{
calcField("Function", FormSelect(FormSelectProps{
Value: e.fn.Get(), Small: true,
OnChange: func(v string) {
e.fn.Set(v)
e.errorMsg.Set("")
},
}, fnOpts...)),
}
switch {
case summary:
children = append(children, calcField("Aggregate down this column",
FormSelect(FormSelectProps{
Value: e.operandAt(0), Small: true,
OnChange: func(v string) { e.operands.Set([]string{v}) },
}, selectOpts...)))
case isBinaryCalcFn(e.currentFn()):
first, second := "Value", "Minus"
if e.currentFn() == CALC_FN_DIVIDE {
first, second = "Numerator", "Denominator"
}
children = append(children, vdom.Div(vdom.Attr("class", "grid grid-cols-2 gap-2"),
calcField(first, FormSelect(FormSelectProps{
Value: e.operandAt(0), Small: true,
OnChange: func(v string) { e.setOperand(0, v) },
}, selectOpts...)),
calcField(second, FormSelect(FormSelectProps{
Value: e.operandAt(1), Small: true,
OnChange: func(v string) { e.setOperand(1, v) },
}, selectOpts...)),
))
default:
msOpts := make([]FormSelectOption, 0, len(opts))
for _, o := range opts {
msOpts = append(msOpts, FormSelectOption{Value: o.Key, Label: o.Label})
}
children = append(children, calcField("Columns (combined per row)",
FormMultiSelect(FormMultiSelectProps{
Options: msOpts,
Value: e.operands.Get(),
Placeholder: "Select columns…",
Searchable: true,
Small: true,
Open: e.opOpen.Get(),
OnToggle: func() { e.opOpen.Set(!e.opOpen.Get()) },
OnChange: func(v []string) { e.operands.Set(v) },
})))
}
children = append(children, vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
vdom.Text(calcBasicHint(e, summary))))
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-2")}, children)...)
}
// calcBasicHint spells out what the current choice will actually do — the single
// most confusable thing about this model.
func calcBasicHint(e *calcEditor, summary bool) string {
switch {
case summary:
return "Aggregates one column down the whole filtered table."
case isBinaryCalcFn(e.currentFn()):
return "Two columns, in order, combined within each row."
default:
return "The chosen columns are combined ACROSS each row — not down the table. " +
"For a column total, add a summary row instead."
}
}
// calcAdvancedEditor: the formula box, its insert menus, and a live preview.
func (s *AutoTableState) calcAdvancedEditor(e *calcEditor, summary bool) *vdom.VNode {
src := e.formula.Get()
opts := s.operandOptions(e.editingID.Get())
// --- insert menus ---
colItems := make([]*vdom.VNode, 0, len(opts)*2)
for _, o := range opts {
label := o.Label
colItems = append(colItems,
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("[" + label + "]") }},
vdom.Span(vdom.Attr("class", "font-mono text-sky-600"), vdom.Text("["+label+"]")),
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("this row")),
),
e.colMenu.Item(MenuItemProps{OnClick: func() { e.insertAtCaret("{" + label + "}") }},
vdom.Span(vdom.Attr("class", "font-mono text-violet-600"), vdom.Text("{"+label+"}")),
vdom.Span(vdom.Attr("class", "ml-2 text-xs text-neutral-500"), vdom.Text("whole column")),
),
)
}
fnItems := make([]*vdom.VNode, 0, len(FormulaFunctionNames))
for _, name := range FormulaFunctionNames {
fname := name
fnItems = append(fnItems, e.fnMenu.Item(MenuItemProps{
OnClick: func() { e.insertAtCaret(fname + "()") },
}, vdom.Span(vdom.Attr("class", "font-mono text-emerald-700"), vdom.Text(fname))))
}
constNames := make([]string, 0, len(FormulaConstants))
for name := range FormulaConstants {
constNames = append(constNames, name)
}
sort.Strings(constNames)
constItems := make([]*vdom.VNode, 0, len(constNames))
for _, name := range constNames {
cname := name
constItems = append(constItems, e.constMenu.Item(MenuItemProps{
OnClick: func() { e.insertAtCaret(cname) },
}, vdom.Span(vdom.Attr("class", "font-mono text-amber-600"), vdom.Text(cname))))
}
menus := vdom.Div(vdom.Attr("class", "flex flex-wrap items-center gap-1"),
e.colMenu.TriggerFunc(MenuTriggerProps{Tag: "div"}, func(bool) *vdom.VNode {
return vdom.Span(vdom.Attr("class", calcMenuTrigger), vdom.Text("Column"), Icon("caret-down", 12, ""))
}),
e.colMenu.Content("max-h-64 overflow-y-auto", colItems...),
e.fnMenu.TriggerFunc(MenuTriggerProps{Tag: "div"}, func(bool) *vdom.VNode {
return vdom.Span(vdom.Attr("class", calcMenuTrigger), vdom.Text("Function"), Icon("caret-down", 12, ""))
}),
e.fnMenu.Content("max-h-64 overflow-y-auto", fnItems...),
e.constMenu.TriggerFunc(MenuTriggerProps{Tag: "div"}, func(bool) *vdom.VNode {
return vdom.Span(vdom.Attr("class", calcMenuTrigger), vdom.Text("Constant"), Icon("caret-down", 12, ""))
}),
e.constMenu.Content("", constItems...),
)
// The highlighted overlay, and the transparent textarea on top of it.
//
// The overlay is rendered declaratively (a signal write per keystroke re-renders
// anyway), so it cannot drift out of sync with the value the way an imperatively
// repainted one can. Its scroll, though, must follow the textarea's — the caret
// runs off the right edge on a long formula, and the colours have to travel with
// it.
overlayHTML := formulaPlaceholder
if strings.TrimSpace(src) != "" {
overlayHTML = HighlightFormula(src)
}
editor := vdom.Div(vdom.Attr("class", "relative"),
vdom.Div(vdom.WithRef(e.overlayRef),
vdom.Attr("class", FORMULA_OVERLAY_CLS),
vdom.Attr("aria-hidden", "true"),
vdom.Raw(overlayHTML),
),
vdom.Textarea(vdom.WithRef(e.formulaRef),
vdom.Attr("class", FORMULA_TEXTAREA_CLS),
vdom.Attr("rows", "1"),
vdom.Attr("wrap", "off"),
vdom.Attr("spellcheck", "false"),
vdom.Prop("value", src),
vdom.OnEvent(vdom.EVENT_INPUT, func(ev vdom.Event) {
e.formula.Set(ev.Value())
e.errorMsg.Set("")
}),
vdom.On(vdom.EVENT_SCROLL, e.syncOverlayScroll),
),
)
help := vdom.P(vdom.Attr("class", "text-xs text-neutral-500"),
vdom.Text("[Column] is this row's cell · {Column} is the whole column · {Column:1:ROW()} is a running total."))
children := []*vdom.VNode{menus, editor, help}
if status := s.calcPreview(e, src, summary); status != nil {
children = append(children, status)
}
return calcField("Formula", children...)
}
// syncOverlayScroll keeps the colours under the caret. Imperative on purpose: this
// fires on every scroll frame, and a signal write would re-render the whole table.
func (e *calcEditor) syncOverlayScroll() {
wasmruntime.SetScrollLeft(e.overlayRef, wasmruntime.ScrollLeft(e.formulaRef))
}
// calcPreview compiles the draft and evaluates it against the table's real first row.
// A formula that does not compile says why; one that does shows what it will produce.
func (s *AutoTableState) calcPreview(e *calcEditor, src string, summary bool) *vdom.VNode {
if strings.TrimSpace(src) == "" {
return nil
}
if _, err := CompileFormula(src); err != nil {
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
}
rows := s.FilteredRows()
if len(rows) == 0 {
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text("Formula is valid."))
}
ctx := NewCalcContext(rows, s.cols, s.Calculated(), s.read)
// A footer row is evaluated with NO current row, so [Cell] and ROW() are NaN
// there — preview it the way it will actually run.
target, prefix := ctx.ForRow(0), "First row → "
if summary {
target, prefix = ctx.ForRow(NoRow), "Result → "
}
n, err := EvalFormula(src, target)
if err != nil {
return vdom.P(vdom.Attr("class", "text-xs text-red-600"), vdom.Text(err.Error()))
}
out := FormatCalcResult(n, CalculatedDataType(e.dataType.Get()), calcPrecisionOf(e.precision.Get()), "", "", "")
return vdom.P(vdom.Attr("class", "text-xs text-neutral-500"), vdom.Text(prefix+out))
}
// insertAtCaret drops text where the cursor is, rather than at the end — which is
// the whole point of an insert menu. It needs the caret position and focus back
// afterwards, both of which come from the host API.
func (e *calcEditor) insertAtCaret(text string) {
src := e.formula.Get()
start, end := wasmruntime.SelectionRange(e.formulaRef)
if start < 0 || start > len(src) || end < start || end > len(src) {
start, end = len(src), len(src) // not mounted (or SSR): append
}
e.formula.Set(src[:start] + text + src[end:])
e.errorMsg.Set("")
// The textarea's value is written by the render this signal just scheduled, so the
// caret can only be placed once that render has landed.
caret := start + len(text)
wasmruntime.AfterRender(func() {
wasmruntime.Focus(e.formulaRef)
wasmruntime.SetSelectionRange(e.formulaRef, caret, caret)
e.syncOverlayScroll()
})
}
func calcField(label string, children ...*vdom.VNode) *vdom.VNode {
nodes := []*vdom.VNode{
vdom.Span(vdom.Attr("class", "text-xs font-medium text-neutral-600"), vdom.Text(label)),
}
nodes = append(nodes, children...)
return vdom.Div(kids([]vdom.Mod{vdom.Attr("class", "flex flex-col gap-1")}, nodes)...)
}
// calcValidate returns the reason the current form cannot be saved, or "".
//
// The operand rules are the model's, not the form's: a binary function needs both of
// its ordered operands, everything else needs at least one, and a formula has to
// compile. Saving something that cannot evaluate would add a column of dashes and
// leave the user with no idea why.
func (s *AutoTableState) calcValidate(e *calcEditor, summary bool) string {
if strings.TrimSpace(e.name.Get()) == "" {
return "Enter a name."
}
if e.advanced.Get() {
if strings.TrimSpace(e.formula.Get()) == "" {
return "Enter a formula."
}
if _, err := CompileFormula(e.formula.Get()); err != nil {
return err.Error()
}
return ""
}
operands := nonEmpty(e.operands.Get())
switch {
case summary:
if len(operands) < 1 {
return "Pick a column to aggregate."
}
case isBinaryCalcFn(e.currentFn()):
if len(operands) < 2 {
return "Pick both columns."
}
default:
if len(operands) < 1 {
return "Pick at least one column."
}
}
return ""
}
func nonEmpty(xs []string) []string {
out := make([]string, 0, len(xs))
for _, x := range xs {
if strings.TrimSpace(x) != "" {
out = append(out, x)
}
}
return out
}
func (s *AutoTableState) saveCalc(e *calcEditor, summary bool) {
if msg := s.calcValidate(e, summary); msg != "" {
e.errorMsg.Set(msg)
return
}
fn := e.currentFn()
operands := nonEmpty(e.operands.Get())
formula := strings.TrimSpace(e.formula.Get())
if e.advanced.Get() {
// An advanced definition is always saved as "custom": the formula IS the
// definition, and a stale basic fn/operand pair left beside it would be a
// second, contradictory source of truth.
fn = CALC_FN_CUSTOM
operands = nil
} else {
formula = ""
// A binary function's operands are positional, so only the first two mean
// anything — the engine would silently ignore the rest.
if isBinaryCalcFn(fn) && len(operands) > 2 {
operands = operands[:2]
}
}
if summary {
s.AddSummaryRow(UserSummaryRow{
ID: e.editingID.Get(),
Label: e.name.Get(),
Fn: fn,
Operands: operands,
Formula: formula,
DataType: CalculatedDataType(e.dataType.Get()),
Precision: calcPrecisionOf(e.precision.Get()),
})
} else {
s.AddCalculated(UserCalculatedColumn{
ID: e.editingID.Get(),
DisplayName: e.name.Get(),
Fn: fn,
Operands: operands,
Formula: formula,
DataType: CalculatedDataType(e.dataType.Get()),
Precision: calcPrecisionOf(e.precision.Get()),
DisplayPosition: ColumnPosition(e.position.Get()),
})
}
e.reset()
}
// calcPrecisionOf turns the form's text into the *int the model wants. Blank means
// unset, which is NOT the same as 0: unset formats naturally ("1,234.5"), 0 rounds to
// whole numbers ("1,235").
func calcPrecisionOf(s string) *int {
s = strings.TrimSpace(s)
if s == "" {
return nil
}
n, err := strconv.Atoi(s)
if err != nil || n < 0 {
return nil
}
return CalcPrecision(n)
}
// ==========================================================================
// The formula engine: tokenizer, parser, evaluator
// ==========================================================================
// AutoTable's calculated columns and summary rows: an Excel-style formula engine,
// ported from AutoTable.tsx. A user builds a column at runtime either from one of
// the ten predefined functions over a list of operand columns, or from a custom
// formula — a small expression language with operators, ~30 functions, and
// references that reach both across columns and down rows:
//
// [Name] the current row's value for that column (a scalar)
// {Name} that column's value in every row (an array)
// {Name:n} the nth (1-based) row of that column (a scalar)
// {Name:a:b} rows a..b inclusive (an array; Excel's ":" range)
//
// e.g. [Revenue] / SUM({Revenue}) * 100 (% of the column total)
// SUM({Revenue:1:ROW()}) (running total to the current row)
// AVERAGE({Sales:ROW()-2:ROW()}) (trailing 3-row moving average)
//
// Indices and bounds are themselves expressions, so ROW() composes with them.
// Aggregate functions flatten array arguments; arithmetic and comparisons are
// scalar-only (an array operand yields NaN). Everything here is pure Go — no DOM
// — so it runs under wasm, on the server, and in tests.
//
// Two layers, deliberately separable:
//
// - CompileFormula parses a formula once (it is the only thing that reports a
// syntax error) into a *Formula that evaluates against any FormulaContext.
// - CalcContext is the standard context: it resolves [Name] / {Name} against a
// set of rows plus the user's other calculated columns, with cycle detection.
//
// On errors, and the one place this port deliberately parts company with the TSX:
// the original signals every RUNTIME problem — an unknown column, a cycle, a
// division by zero — by quietly returning NaN, which the display path then renders
// as the empty value. Silently correct, but it leaves a user with a broken formula
// staring at "—" with no idea why. Here evaluation still produces NaN (so the
// rendered result is identical), but the first fault is also returned as an error,
// which the editor can surface. Callers that only want the TSX's behavior ignore
// it — FormatCalculatedColumn does exactly that.
// CalculatedFunction is a predefined (non-formula) function for a calculated
// column or a summary row. The aggregates (sum/average/median/mode/min/max/count)
// ignore blank and non-numeric operands, the way Excel's do; the arithmetic ones
// (subtract/multiply/divide) require every operand to be a number and otherwise
// yield NaN. CALC_FN_CUSTOM instead evaluates the column's Formula.
type CalculatedFunction string
const (
CALC_FN_SUM CalculatedFunction = "sum"
CALC_FN_SUBTRACT CalculatedFunction = "subtract"
CALC_FN_MULTIPLY CalculatedFunction = "multiply"
CALC_FN_DIVIDE CalculatedFunction = "divide"
CALC_FN_AVERAGE CalculatedFunction = "average"
CALC_FN_MEDIAN CalculatedFunction = "median"
CALC_FN_MODE CalculatedFunction = "mode"
CALC_FN_MIN CalculatedFunction = "min"
CALC_FN_MAX CalculatedFunction = "max"
CALC_FN_COUNT CalculatedFunction = "count"
CALC_FN_CUSTOM CalculatedFunction = "custom"
)
// CalculatedDataType drives how a calculated result is formatted for display, CSV
// and PDF. See FormatCalcResult.
type CalculatedDataType string
const (
CALC_TYPE_PLAIN CalculatedDataType = "plain"
CALC_TYPE_NUMBER CalculatedDataType = "number"
CALC_TYPE_INTEGER CalculatedDataType = "integer"
CALC_TYPE_DECIMAL CalculatedDataType = "decimal"
CALC_TYPE_MONEY CalculatedDataType = "money"
CALC_TYPE_PERCENT CalculatedDataType = "percent"
)
// CalcEmptyValue is what a result that is not a finite number renders as.
const CalcEmptyValue = "—"
// CalcRefPrefix marks an operand that names another calculated column rather than
// a data field. Data operands are a column's SortIdentifier (the row field to
// read); calculated ones are CalcRef(id).
const CalcRefPrefix = "_calc_"
// CalcRef is the operand key (and sort identifier) of a calculated column.
func CalcRef(id string) string { return CalcRefPrefix + id }
// UserCalculatedColumn is a column the user builds at runtime. For a predefined
// Fn, Operands name the columns to combine (a data column's SortIdentifier, or
// CalcRef(id) for another calculated column). For CALC_FN_CUSTOM, Formula holds an
// expression that names columns by display name — [Revenue], {Revenue}.
type UserCalculatedColumn struct {
ID string
DisplayName string
Fn CalculatedFunction
Operands []string
Formula string
DataType CalculatedDataType
// Precision is the fixed number of decimal places. It is a pointer because
// "unset" and "zero" mean different things: CALC_TYPE_NUMBER with no precision
// formats naturally ("1,234.5"), with precision 0 it rounds ("1,235").
Precision *int
DisplayPosition ColumnPosition
}
// UserSummaryRow is a footer line (Total, Subtotal, …). Unlike a calculated
// column it is evaluated ONCE, with no current row, so [cell] references and ROW()
// are NaN and only whole-column aggregates like SUM({Revenue}) are meaningful. In
// basic mode Fn aggregates Operands[0] down the rows; in custom mode Formula is
// evaluated.
type UserSummaryRow struct {
ID string
Label string
Fn CalculatedFunction
Operands []string
Formula string
DataType CalculatedDataType
Precision *int
}
// CalcPrecision is the pointer form of a precision, for a struct literal:
// UserCalculatedColumn{..., Precision: CalcPrecision(0)}.
func CalcPrecision(n int) *int { return &n }
// Faults an evaluation can report. They are diagnostics: the value NaN is produced
// regardless, so a table renders the empty value rather than failing (see the note
// at the top of the file).
var (
ErrEmptyFormula = errors.New("empty formula")
ErrFormulaSyntax = errors.New("formula syntax error")
ErrUnknownFunction = errors.New("unknown function")
ErrUnknownName = errors.New("unknown name")
ErrUnknownColumn = errors.New("unknown column")
ErrFormulaCycle = errors.New("formula cycle")
ErrDivideByZero = errors.New("division by zero")
)
// ---- reading and combining operand values ----
// ToCalcNumber parses a row value as a number, tolerating the formatted strings a
// row often carries ("$1,234.56", "12%"). NaN when there is nothing numeric to
// read, which is how a blank cell drops out of an aggregate.
func ToCalcNumber(v any) float64 {
if f, ok := toFloat(v); ok {
return f
}
if v == nil {
return math.NaN()
}
cleaned := strings.Map(func(r rune) rune {
switch r {
case '$', ',', '%', ' ', '\t', '\n', '\r', '\v', '\f':
return -1
}
return r
}, stringify(v))
if cleaned == "" {
return math.NaN()
}
return calcParseFloat(cleaned)
}
// calcParseFloat mirrors JavaScript's parseFloat: it reads the longest numeric
// prefix and ignores the rest ("12abc" is 12), and is NaN when there is no leading
// number at all. strconv.ParseFloat rejects both, and a stricter rule here would
// silently drop values the TSX accepts.
func calcParseFloat(s string) float64 {
i := 0
if i < len(s) && (s[i] == '+' || s[i] == '-') {
i++
}
digits := false
for i < len(s) && isDigit(s[i]) {
i++
digits = true
}
if i < len(s) && s[i] == '.' {
i++
for i < len(s) && isDigit(s[i]) {
i++
digits = true
}
}
if !digits {
return math.NaN()
}
end := i
if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
j := i + 1
if j < len(s) && (s[j] == '+' || s[j] == '-') {
j++
}
k := j
for k < len(s) && isDigit(s[k]) {
k++
}
if k > j {
end = k
}
}
f, err := strconv.ParseFloat(s[:end], 64)
if err != nil {
return math.NaN()
}
return f
}
// ApplyCalcFunction combines operand values with a predefined function. The
// aggregates skip NaN operands (a blank cell is not a zero); the arithmetic ones
// propagate NaN, so a row missing one of its operands shows the empty value rather
// than a plausible-looking wrong number.
//
// An empty operand list is NaN — except for COUNT, which is 0. MODE is NaN when no
// value repeats. Division by zero is NaN.
func ApplyCalcFunction(fn CalculatedFunction, operands []float64) float64 {
if fn == CALC_FN_COUNT {
return float64(len(calcNumbers(operands)))
}
if len(operands) == 0 {
return math.NaN()
}
switch fn {
case CALC_FN_SUM, CALC_FN_AVERAGE, CALC_FN_MIN, CALC_FN_MAX:
nums := calcNumbers(operands)
if len(nums) == 0 {
return math.NaN()
}
switch fn {
case CALC_FN_SUM:
return calcTotal(nums)
case CALC_FN_AVERAGE:
return calcTotal(nums) / float64(len(nums))
case CALC_FN_MIN:
out := nums[0]
for _, n := range nums[1:] {
out = math.Min(out, n)
}
return out
default:
out := nums[0]
for _, n := range nums[1:] {
out = math.Max(out, n)
}
return out
}
case CALC_FN_MEDIAN:
nums := calcNumbers(operands)
if len(nums) == 0 {
return math.NaN()
}
sort.Float64s(nums)
mid := len(nums) / 2
if len(nums)%2 == 1 {
return nums[mid]
}
return (nums[mid-1] + nums[mid]) / 2
case CALC_FN_MODE:
nums := calcNumbers(operands)
counts := make(map[float64]int, len(nums))
best, bestCount := math.NaN(), 0
for _, n := range nums {
counts[n]++
if counts[n] > bestCount {
bestCount, best = counts[n], n
}
}
if bestCount > 1 {
return best // no repeated value -> no mode
}
return math.NaN()
case CALC_FN_SUBTRACT:
if calcHasNaN(operands) {
return math.NaN()
}
out := operands[0]
for _, n := range operands[1:] {
out -= n
}
return out
case CALC_FN_MULTIPLY:
if calcHasNaN(operands) {
return math.NaN()
}
out := 1.0
for _, n := range operands {
out *= n
}
return out
case CALC_FN_DIVIDE:
if calcHasNaN(operands) {
return math.NaN()
}
out := operands[0]
for _, n := range operands[1:] {
if n == 0 {
return math.NaN()
}
out /= n
}
return out
}
return math.NaN()
}
// calcNumbers drops the NaNs (a copy: median sorts it in place).
func calcNumbers(operands []float64) []float64 {
out := make([]float64, 0, len(operands))
for _, n := range operands {
if !math.IsNaN(n) {
out = append(out, n)
}
}
return out
}
func calcHasNaN(operands []float64) bool {
for _, n := range operands {
if math.IsNaN(n) {
return true
}
}
return false
}
func calcTotal(nums []float64) float64 {
var sum float64
for _, n := range nums {
sum += n
}
return sum
}
// ---- the formula language ----
// FormulaContext resolves the references a formula makes. CalcContext is the
// standard implementation; implement it yourself to evaluate a formula against
// something that is not an AutoTable.
type FormulaContext interface {
// Cell is [Name]: the named column's value in the current row. NaN — not an
// error — when the row simply has no numeric value there, or when there is no
// current row at all (a summary). The error is for a name that is not a column.
Cell(name string) (float64, error)
// Column is {Name}: the named column's value in every row.
Column(name string) ([]float64, error)
// Row is ROW(): the current row's 1-based position, NaN when there is none.
Row() float64
}
// formulaValue is the TSX's `number | number[]`: a formula's operands are scalars,
// but a {Column} reference produces the whole column and aggregates flatten it.
type formulaValue struct {
num float64
arr []float64
isArray bool
}
func scalarValue(n float64) formulaValue { return formulaValue{num: n} }
func arrayValue(a []float64) formulaValue { return formulaValue{arr: a, isArray: true} }
func (v formulaValue) scalar() float64 {
if v.isArray {
return math.NaN() // an array in scalar position is not a number
}
return v.num
}
// evalState carries the context plus the first runtime fault. Faults do not stop
// evaluation (the TSX has no way to stop); they are collected and returned.
type evalState struct {
ctx FormulaContext
err error
}
func (s *evalState) fail(err error) {
if s.err == nil {
s.err = err
}
}
type formulaNode func(s *evalState) formulaValue
// Formula is a parsed formula, ready to evaluate against any context. Compile once
// (per column) and evaluate per row — parsing is by far the expensive half.
type Formula struct {
root formulaNode
src string
}
// Source is the formula text this was compiled from.
func (f *Formula) Source() string { return f.src }
// Eval evaluates the formula. The result is NaN when the formula is not
// computable; err additionally names the first fault (see the note at the top of
// the file). A nil context resolves every reference to NaN, which is enough for a
// formula of pure literals and constants.
func (f *Formula) Eval(ctx FormulaContext) (float64, error) {
if ctx == nil {
ctx = emptyFormulaContext{}
}
s := &evalState{ctx: ctx}
v := f.root(s)
return v.scalar(), s.err
}
// EvalFormula compiles and evaluates in one step. Prefer CompileFormula when the
// same formula runs over many rows.
func EvalFormula(formula string, ctx FormulaContext) (float64, error) {
f, err := CompileFormula(formula)
if err != nil {
return math.NaN(), err
}
return f.Eval(ctx)
}
// emptyFormulaContext knows no columns and has no current row.
type emptyFormulaContext struct{}
func (emptyFormulaContext) Cell(name string) (float64, error) {
return math.NaN(), fmt.Errorf("%w: %q", ErrUnknownColumn, name)
}
func (emptyFormulaContext) Column(name string) ([]float64, error) {
return nil, fmt.Errorf("%w: %q", ErrUnknownColumn, name)
}
func (emptyFormulaContext) Row() float64 { return math.NaN() }
// FormulaConstants are the names a formula can write bare, e.g. 2 * PI * [r].
// Matched case-insensitively.
var FormulaConstants = map[string]float64{
"PI": math.Pi,
"E": math.E,
"TAU": math.Pi * 2,
"PHI": (1 + math.Sqrt(5)) / 2, // the golden ratio
"SQRT2": math.Sqrt2,
}
// ---- tokenizer ----
type formulaTokenKind int
const (
tokNum formulaTokenKind = iota
tokID
tokOp
tokPunc
tokRef // [Name] -- current-row cell
tokColRef // {Name}, {Name:i}, {Name:a:b} -- raw, split at parse time
)
type formulaToken struct {
kind formulaTokenKind
text string
}
func tokenizeFormula(src string) ([]formulaToken, error) {
var tokens []formulaToken
i := 0
for i < len(src) {
c := src[i]
switch {
case c == ' ' || c == '\t' || c == '\n' || c == '\r':
i++
case c == '[':
end := strings.IndexByte(src[i+1:], ']')
if end < 0 {
return nil, fmt.Errorf("%w: unclosed '[' reference", ErrFormulaSyntax)
}
end += i + 1
tokens = append(tokens, formulaToken{tokRef, strings.TrimSpace(src[i+1 : end])})
i = end + 1
case c == '{':
// Balanced-brace scan, so a nested {Col:...} used as an index survives
// intact ({A:{B:1}}).
depth, j := 1, i+1
for ; j < len(src); j++ {
if src[j] == '{' {
depth++
} else if src[j] == '}' {
depth--
if depth == 0 {
break
}
}
}
if depth != 0 {
return nil, fmt.Errorf("%w: unclosed '{' column reference", ErrFormulaSyntax)
}
tokens = append(tokens, formulaToken{tokColRef, strings.TrimSpace(src[i+1 : j])})
i = j + 1
case isDigit(c) || (c == '.' && i+1 < len(src) && isDigit(src[i+1])):
j := i + 1
for j < len(src) && (isDigit(src[j]) || src[j] == '.') {
j++
}
tokens = append(tokens, formulaToken{tokNum, src[i:j]})
i = j
case isFormulaAlpha(c):
j := i + 1
for j < len(src) && (isFormulaAlpha(src[j]) || isDigit(src[j])) {
j++
}
tokens = append(tokens, formulaToken{tokID, src[i:j]})
i = j
default:
if i+1 < len(src) {
if two := src[i : i+2]; two == "<=" || two == ">=" || two == "<>" {
tokens = append(tokens, formulaToken{tokOp, two})
i += 2
continue
}
}
switch {
case strings.IndexByte("+-*/^%=<>", c) >= 0:
tokens = append(tokens, formulaToken{tokOp, string(c)})
i++
case strings.IndexByte("(),", c) >= 0:
tokens = append(tokens, formulaToken{tokPunc, string(c)})
i++
default:
return nil, fmt.Errorf("%w: unexpected character %q", ErrFormulaSyntax, string(c))
}
}
}
return tokens, nil
}
// isFormulaAlpha: identifiers are letters and '_' (digits may follow).
func isFormulaAlpha(c byte) bool {
return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || c == '_'
}
// topLevelColon is the index of the first ':' at brace/bracket/paren depth 0, or
// -1. It splits a column reference's name from its row index/range without
// tripping on a ':' inside a nested {ref} or a function's arguments.
func topLevelColon(s string) int {
depth := 0
for i := 0; i < len(s); i++ {
switch s[i] {
case '{', '[', '(':
depth++
case '}', ']', ')':
depth--
case ':':
if depth == 0 {
return i
}
}
}
return -1
}
// ---- parser ----
//
// Recursive descent, with the TSX's precedence exactly:
//
// comparison (= <> < > <= >=) lowest, left-assoc
// + - left-assoc
// * / % left-assoc ('%' is remainder, not percent)
// ^ right-assoc
// unary - + binds TIGHTER than '^', so -2^2 is 4 (as in Excel)
// primary literal, constant, call, [ref], {colref}, ( … )
// CompileFormula parses a formula. It is the only step that reports a syntax
// error; everything that can go wrong later (an unknown column, a cycle, a
// division by zero) surfaces at evaluation.
func CompileFormula(src string) (*Formula, error) {
tokens, err := tokenizeFormula(src)
if err != nil {
return nil, err
}
if len(tokens) == 0 {
return nil, ErrEmptyFormula
}
p := &formulaParser{tokens: tokens}
root, err := p.parseComparison()
if err != nil {
return nil, err
}
if p.pos < len(p.tokens) {
return nil, fmt.Errorf("%w: unexpected token %q", ErrFormulaSyntax, p.tokens[p.pos].text)
}
return &Formula{root: root, src: src}, nil
}
type formulaParser struct {
tokens []formulaToken
pos int
}
func (p *formulaParser) peek() (formulaToken, bool) {
if p.pos >= len(p.tokens) {
return formulaToken{}, false
}
return p.tokens[p.pos], true
}
func (p *formulaParser) next() (formulaToken, bool) {
t, ok := p.peek()
if ok {
p.pos++
}
return t, ok
}
func (p *formulaParser) expect(text string) error {
t, ok := p.next()
if !ok || t.text != text {
return fmt.Errorf("%w: expected %q", ErrFormulaSyntax, text)
}
return nil
}
// isOp reports whether the next token is one of the given operators.
func (p *formulaParser) isOp(ops ...string) bool {
t, ok := p.peek()
if !ok || t.kind != tokOp {
return false
}
for _, op := range ops {
if t.text == op {
return true
}
}
return false
}
func (p *formulaParser) isPunc(text string) bool {
t, ok := p.peek()
return ok && t.kind == tokPunc && t.text == text
}
// A comparison yields 1 or 0, never a bool: the result feeds straight back into
// arithmetic ([Qty] > 10) * 5. A NaN operand compares as 0, not NaN — so a blank
// cell fails a test rather than poisoning it.
func (p *formulaParser) parseComparison() (formulaNode, error) {
left, err := p.parseAddSub()
if err != nil {
return nil, err
}
for p.isOp("=", "<>", "<", ">", "<=", ">=") {
op, _ := p.next()
right, err := p.parseAddSub()
if err != nil {
return nil, err
}
l, r, o := left, right, op.text
left = func(s *evalState) formulaValue {
a, b := l(s).scalar(), r(s).scalar()
if math.IsNaN(a) || math.IsNaN(b) {
return scalarValue(0)
}
var got bool
switch o {
case "=":
got = a == b
case "<>":
got = a != b
case "<":
got = a < b
case ">":
got = a > b
case "<=":
got = a <= b
default:
got = a >= b
}
if got {
return scalarValue(1)
}
return scalarValue(0)
}
}
return left, nil
}
func (p *formulaParser) parseAddSub() (formulaNode, error) {
left, err := p.parseMulDiv()
if err != nil {
return nil, err
}
for p.isOp("+", "-") {
op, _ := p.next()
right, err := p.parseMulDiv()
if err != nil {
return nil, err
}
l, r, plus := left, right, op.text == "+"
left = func(s *evalState) formulaValue {
a, b := l(s).scalar(), r(s).scalar()
if plus {
return scalarValue(a + b)
}
return scalarValue(a - b)
}
}
return left, nil
}
func (p *formulaParser) parseMulDiv() (formulaNode, error) {
left, err := p.parsePow()
if err != nil {
return nil, err
}
for p.isOp("*", "/", "%") {
op, _ := p.next()
right, err := p.parsePow()
if err != nil {
return nil, err
}
l, r, o := left, right, op.text
left = func(s *evalState) formulaValue {
a, b := l(s).scalar(), r(s).scalar()
if o == "*" {
return scalarValue(a * b)
}
if b == 0 {
s.fail(ErrDivideByZero)
return scalarValue(math.NaN())
}
if o == "/" {
return scalarValue(a / b)
}
return scalarValue(math.Mod(a, b)) // '%' is the remainder, as in Excel's MOD
}
}
return left, nil
}
// '^' is right-associative: 2^3^2 is 2^(3^2).
func (p *formulaParser) parsePow() (formulaNode, error) {
left, err := p.parseUnary()
if err != nil {
return nil, err
}
if !p.isOp("^") {
return left, nil
}
p.next()
right, err := p.parsePow()
if err != nil {
return nil, err
}
l, r := left, right
return func(s *evalState) formulaValue {
return scalarValue(math.Pow(l(s).scalar(), r(s).scalar()))
}, nil
}
func (p *formulaParser) parseUnary() (formulaNode, error) {
if p.isOp("-") {
p.next()
operand, err := p.parseUnary()
if err != nil {
return nil, err
}
return func(s *evalState) formulaValue { return scalarValue(-operand(s).scalar()) }, nil
}
if p.isOp("+") {
p.next()
return p.parseUnary()
}
return p.parsePrimary()
}
func (p *formulaParser) parsePrimary() (formulaNode, error) {
t, ok := p.peek()
if !ok {
return nil, fmt.Errorf("%w: unexpected end of formula", ErrFormulaSyntax)
}
switch t.kind {
case tokNum:
p.next()
// Divergence, deliberate: the tokenizer accepts any run of digits and dots,
// so "1.2.3" reaches here. JavaScript's parseFloat quietly reads it as 1.2;
// a malformed literal is a typo, and a typo should be a syntax error.
v, err := strconv.ParseFloat(t.text, 64)
if err != nil {
return nil, fmt.Errorf("%w: invalid number %q", ErrFormulaSyntax, t.text)
}
return func(*evalState) formulaValue { return scalarValue(v) }, nil
case tokRef:
p.next()
name := t.text
return func(s *evalState) formulaValue {
n, err := s.ctx.Cell(name)
if err != nil {
s.fail(err)
}
return scalarValue(n)
}, nil
case tokColRef:
p.next()
return p.colRefNode(t.text)
case tokPunc:
if t.text == "(" {
p.next()
inner, err := p.parseComparison()
if err != nil {
return nil, err
}
if err := p.expect(")"); err != nil {
return nil, err
}
return inner, nil
}
case tokID:
p.next()
// A bare identifier (no '(' after it) is a named constant, not a call.
if !p.isPunc("(") {
v, ok := FormulaConstants[strings.ToUpper(t.text)]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownName, t.text)
}
return func(*evalState) formulaValue { return scalarValue(v) }, nil
}
p.next() // '('
var args []formulaNode
if !p.isPunc(")") {
for {
arg, err := p.parseComparison()
if err != nil {
return nil, err
}
args = append(args, arg)
if !p.isPunc(",") {
break
}
p.next()
}
}
if err := p.expect(")"); err != nil {
return nil, err
}
return formulaFunction(strings.ToUpper(t.text), args)
}
return nil, fmt.Errorf("%w: unexpected token %q", ErrFormulaSyntax, t.text)
}
// colRefNode compiles the inside of a {…}: a bare name, a name plus an index, or a
// name plus a range. Indices are full expressions (compiled recursively), which is
// what makes SUM({Revenue:1:ROW()}) — a running total — work.
func (p *formulaParser) colRefNode(raw string) (formulaNode, error) {
ci := topLevelColon(raw)
if ci < 0 {
name := strings.TrimSpace(raw)
return func(s *evalState) formulaValue {
col, err := s.ctx.Column(name)
if err != nil {
s.fail(err)
}
return arrayValue(col)
}, nil
}
name := strings.TrimSpace(raw[:ci])
spec := strings.TrimSpace(raw[ci+1:])
// {Name:i} -- one row, 1-based. Out of range is NaN, not an error: a formula
// like {Sales:ROW()-1} is expected to run off the top on the first row.
ri := topLevelColon(spec)
if ri < 0 {
idx, err := CompileFormula(spec)
if err != nil {
return nil, err
}
return func(s *evalState) formulaValue {
col, err := s.ctx.Column(name)
if err != nil {
s.fail(err)
}
n := idx.root(s).scalar()
if math.IsNaN(n) {
return scalarValue(math.NaN())
}
i := int(math.Trunc(n)) - 1
if i < 0 || i >= len(col) {
return scalarValue(math.NaN())
}
return scalarValue(col[i])
}, nil
}
// {Name:a:b} -- an inclusive range, clipped to the column. Reversed bounds are
// swapped, so {Sales:ROW():1} means the same as {Sales:1:ROW()}.
start, err := CompileFormula(strings.TrimSpace(spec[:ri]))
if err != nil {
return nil, err
}
end, err := CompileFormula(strings.TrimSpace(spec[ri+1:]))
if err != nil {
return nil, err
}
return func(s *evalState) formulaValue {
col, err := s.ctx.Column(name)
if err != nil {
s.fail(err)
}
lo, hi := start.root(s).scalar(), end.root(s).scalar()
if math.IsNaN(lo) || math.IsNaN(hi) {
return arrayValue(nil)
}
a, b := int(math.Trunc(lo)), int(math.Trunc(hi))
if a > b {
a, b = b, a
}
out := make([]float64, 0, max(b-a+1, 0))
for k := a; k <= b; k++ {
if i := k - 1; i >= 0 && i < len(col) {
out = append(out, col[i])
}
}
return arrayValue(out)
}, nil
}
// ---- functions ----
// formulaFunction binds a call to its implementation, or fails the compile if the
// name is not a function. Aggregates flatten their array arguments (so {Col} spans
// the rows); everything else coerces each argument to a scalar. A missing argument
// is NaN, which is how the optional second argument of ROUND/LOG works.
func formulaFunction(name string, args []formulaNode) (formulaNode, error) {
// arg evaluates the nth argument, NaN when it was not supplied.
arg := func(s *evalState, n int) float64 {
if n >= len(args) {
return math.NaN()
}
return args[n](s).scalar()
}
agg := func(s *evalState) []float64 {
var out []float64
for _, a := range args {
v := a(s)
if v.isArray {
out = append(out, v.arr...)
} else {
out = append(out, v.num)
}
}
return out
}
// A value is "true" if it is a number and not zero.
truthy := func(n float64) bool { return !math.IsNaN(n) && n != 0 }
aggregate := func(fn CalculatedFunction) (formulaNode, error) {
return func(s *evalState) formulaValue { return scalarValue(ApplyCalcFunction(fn, agg(s))) }, nil
}
unary := func(f func(float64) float64) (formulaNode, error) {
return func(s *evalState) formulaValue { return scalarValue(f(arg(s, 0))) }, nil
}
switch name {
case "SUM":
return aggregate(CALC_FN_SUM)
case "AVERAGE", "AVG":
return aggregate(CALC_FN_AVERAGE)
case "MEDIAN":
return aggregate(CALC_FN_MEDIAN)
case "MODE":
return aggregate(CALC_FN_MODE)
case "MIN":
return aggregate(CALC_FN_MIN)
case "MAX":
return aggregate(CALC_FN_MAX)
case "COUNT":
return aggregate(CALC_FN_COUNT)
case "ABS":
return unary(math.Abs)
case "ROUND":
return func(s *evalState) formulaValue {
digits := 0.0
if len(args) > 1 {
digits = arg(s, 1)
}
f := math.Pow(10, digits)
return scalarValue(calcRound(arg(s, 0)*f) / f)
}, nil
case "FLOOR":
return unary(math.Floor)
case "CEILING", "CEIL":
return unary(math.Ceil)
case "SQRT":
return unary(math.Sqrt)
case "POWER":
return func(s *evalState) formulaValue { return scalarValue(math.Pow(arg(s, 0), arg(s, 1))) }, nil
case "MOD":
return func(s *evalState) formulaValue {
b := arg(s, 1)
if b == 0 {
s.fail(ErrDivideByZero)
return scalarValue(math.NaN())
}
return scalarValue(math.Mod(arg(s, 0), b))
}, nil
case "EXP":
return unary(math.Exp)
case "LN":
return unary(math.Log)
case "LOG":
// LOG(n, base) -- base 10 by default, matching Excel.
return func(s *evalState) formulaValue {
base := 10.0
if len(args) > 1 {
base = arg(s, 1)
}
return scalarValue(math.Log(arg(s, 0)) / math.Log(base))
}, nil
// Angles are in radians, like Excel; RADIANS/DEGREES convert.
case "SIN":
return unary(math.Sin)
case "COS":
return unary(math.Cos)
case "TAN":
return unary(math.Tan)
case "ASIN":
return unary(math.Asin)
case "ACOS":
return unary(math.Acos)
case "ATAN":
return unary(math.Atan)
case "ATAN2":
// ATAN2(x, y) -- Excel's argument order (the angle of the point (x, y)),
// which is the reverse of math.Atan2's.
return func(s *evalState) formulaValue {
return scalarValue(math.Atan2(arg(s, 1), arg(s, 0)))
}, nil
case "SINH":
return unary(math.Sinh)
case "COSH":
return unary(math.Cosh)
case "TANH":
return unary(math.Tanh)
case "PI":
return func(*evalState) formulaValue { return scalarValue(math.Pi) }, nil
case "RADIANS":
return unary(func(n float64) float64 { return n * math.Pi / 180 })
case "DEGREES":
return unary(func(n float64) float64 { return n * 180 / math.Pi })
case "IF":
// The untaken branch is not evaluated, so IF([d]=0, 0, [n]/[d]) is safe.
return func(s *evalState) formulaValue {
if truthy(arg(s, 0)) {
return scalarValue(arg(s, 1))
}
if len(args) > 2 {
return scalarValue(arg(s, 2))
}
return scalarValue(0)
}, nil
case "AND":
return func(s *evalState) formulaValue {
for i := range args {
if !truthy(arg(s, i)) {
return scalarValue(0)
}
}
return scalarValue(1)
}, nil
case "OR":
return func(s *evalState) formulaValue {
for i := range args {
if truthy(arg(s, i)) {
return scalarValue(1)
}
}
return scalarValue(0)
}, nil
case "NOT":
return func(s *evalState) formulaValue {
if truthy(arg(s, 0)) {
return scalarValue(0)
}
return scalarValue(1)
}, nil
case "ROW":
return func(s *evalState) formulaValue { return scalarValue(s.ctx.Row()) }, nil
}
return nil, fmt.Errorf("%w: %q", ErrUnknownFunction, name)
}
// calcRound is JavaScript's Math.round, which the TSX's ROUND() and integer
// formatting are built on: it rounds a half UP (toward +Inf), so -2.5 rounds to
// -2. Go's math.Round rounds a half AWAY FROM ZERO (-2.5 to -3). Kept faithful to
// the TSX so the two implementations agree cell for cell.
func calcRound(x float64) float64 {
if math.IsNaN(x) || math.IsInf(x, 0) {
return x
}
f := math.Floor(x)
if x-f >= 0.5 {
f++
}
return f
}
// ---- the standard context: rows + calculated columns ----
// NoRow is the "there is no current row" row index — a summary line. [Cell]
// references and ROW() are NaN under it.
const NoRow = -1
// CalcContext resolves a formula's references against a set of rows (the FILTERED
// rows, unpaginated: an aggregate spans what the user is looking at, not the page
// they happen to be on), the table's columns, and the user's other calculated
// columns.
//
// Build it once per render with NewCalcContext, then derive a per-row view with
// ForRow — the name lookups and compiled formulas are shared, so evaluating a
// column over a thousand rows parses nothing a thousand times.
type CalcContext struct {
rows []any
rowIndex int
read FieldReader
shared *calcShared
visiting map[string]bool // ids of the calc columns being evaluated up the stack
}
type calcShared struct {
byID map[string]UserCalculatedColumn
nameToRef map[string]string // lowercased display name -> operand key
compiled map[string]*Formula // custom formulas, compiled once per render
}
// NewCalcContext builds a context over rows. Columns and calcs are what a formula
// can name: a data column is referenceable when it has a SortIdentifier (that is
// the field its value is read from), a calculated column always is. read may be
// nil for DefaultFieldReader.
//
// The returned context has no current row (NoRow), which is what a summary wants;
// call ForRow for a calculated column.
func NewCalcContext(rows []any, cols []AutoTableColumn, calcs []UserCalculatedColumn, read FieldReader) *CalcContext {
if read == nil {
read = DefaultFieldReader
}
shared := &calcShared{
byID: make(map[string]UserCalculatedColumn, len(calcs)),
nameToRef: make(map[string]string, len(cols)+len(calcs)),
compiled: make(map[string]*Formula),
}
for _, c := range cols {
if c.SortIdentifier != "" {
shared.nameToRef[calcNameKey(c.DisplayName)] = c.SortIdentifier
}
}
// Calculated columns are registered last, so one that shadows a data column's
// display name wins — as in the TSX.
for _, c := range calcs {
shared.byID[c.ID] = c
shared.nameToRef[calcNameKey(c.DisplayName)] = CalcRef(c.ID)
if c.Fn == CALC_FN_CUSTOM {
// A formula that does not compile is left out of the cache; the error
// resurfaces per row from formulaFor, which is where it can be reported.
if f, err := CompileFormula(c.Formula); err == nil {
shared.compiled[c.ID] = f
}
}
}
return &CalcContext{rows: rows, rowIndex: NoRow, read: read, shared: shared}
}
// calcNameKey is how a display name is matched inside a formula: trimmed, and
// case-insensitively.
func calcNameKey(name string) string { return strings.ToLower(strings.TrimSpace(name)) }
// ForRow derives a context whose current row is the 0-based rowIndex (NoRow for
// none). The rows, lookups and compiled formulas are shared.
func (c *CalcContext) ForRow(rowIndex int) *CalcContext {
out := *c
out.rowIndex = rowIndex
return &out
}
// Rows are the rows the context aggregates over.
func (c *CalcContext) Rows() []any { return c.rows }
// RowIndex is the current row's 0-based index, or NoRow.
func (c *CalcContext) RowIndex() int { return c.rowIndex }
// withVisiting marks a calculated column as being evaluated. The set is copied,
// not mutated: two sibling references to the same column are not a cycle, only a
// reference back into the chain above is.
func (c *CalcContext) withVisiting(id string) *CalcContext {
out := *c
out.visiting = make(map[string]bool, len(c.visiting)+1)
for k := range c.visiting {
out.visiting[k] = true
}
out.visiting[id] = true
return &out
}
// Cell implements FormulaContext: [Name] in the current row.
func (c *CalcContext) Cell(name string) (float64, error) {
ref, ok := c.shared.nameToRef[calcNameKey(name)]
if !ok {
return math.NaN(), fmt.Errorf("%w: %q", ErrUnknownColumn, name)
}
return c.resolveRef(ref)
}
// Column implements FormulaContext: {Name} across the rows.
func (c *CalcContext) Column(name string) ([]float64, error) {
ref, ok := c.shared.nameToRef[calcNameKey(name)]
if !ok {
return nil, fmt.Errorf("%w: %q", ErrUnknownColumn, name)
}
return c.resolveColumn(ref)
}
// Row implements FormulaContext: the current row's 1-based position.
func (c *CalcContext) Row() float64 {
if c.rowIndex < 0 || c.rowIndex >= len(c.rows) {
return math.NaN()
}
return float64(c.rowIndex + 1)
}
// resolveRef reads one operand key in the current row: a calculated column
// (evaluated recursively) or a data field.
func (c *CalcContext) resolveRef(ref string) (float64, error) {
if id, ok := strings.CutPrefix(ref, CalcRefPrefix); ok {
target, err := c.calcTarget(id)
if err != nil {
return math.NaN(), err
}
// A calculated column may not need a row at all (SUM({Revenue})), so this
// recurses even when there is no current row, exactly as the TSX does.
return c.withVisiting(id).computeCalc(target)
}
if c.rowIndex < 0 || c.rowIndex >= len(c.rows) {
return math.NaN(), nil // a summary has no current row: not an error
}
return ToCalcNumber(c.read(c.rows[c.rowIndex], ref)), nil
}
// resolveColumn reads one operand key in every row.
func (c *CalcContext) resolveColumn(ref string) ([]float64, error) {
out := make([]float64, len(c.rows))
if id, ok := strings.CutPrefix(ref, CalcRefPrefix); ok {
target, err := c.calcTarget(id)
if err != nil {
return nil, err
}
child := c.withVisiting(id)
var firstErr error
for i := range c.rows {
n, err := child.ForRow(i).computeCalc(target)
if err != nil && firstErr == nil {
firstErr = err
}
out[i] = n
}
return out, firstErr
}
for i, row := range c.rows {
out[i] = ToCalcNumber(c.read(row, ref))
}
return out, nil
}
// calcTarget looks up a calculated column being referenced, refusing one already
// on the evaluation stack — that is the cycle check, and without it a formula that
// names itself would recurse until the stack ran out.
func (c *CalcContext) calcTarget(id string) (UserCalculatedColumn, error) {
if c.visiting[id] {
return UserCalculatedColumn{}, fmt.Errorf("%w: %q references itself", ErrFormulaCycle, id)
}
target, ok := c.shared.byID[id]
if !ok {
return UserCalculatedColumn{}, fmt.Errorf("%w: %q", ErrUnknownColumn, CalcRef(id))
}
return target, nil
}
// computeCalc evaluates one calculated column for the current row. The caller has
// already marked it visiting.
func (c *CalcContext) computeCalc(uc UserCalculatedColumn) (float64, error) {
if uc.Fn == CALC_FN_CUSTOM {
f, err := c.formulaFor(uc)
if err != nil {
return math.NaN(), err
}
return f.Eval(c)
}
operands := make([]float64, len(uc.Operands))
var firstErr error
for i, op := range uc.Operands {
n, err := c.resolveRef(op)
if err != nil && firstErr == nil {
firstErr = err
}
operands[i] = n
}
return ApplyCalcFunction(uc.Fn, operands), firstErr
}
// formulaFor returns the column's compiled formula, from the context's cache when
// the column is one it was built with (the common case: compiled once, evaluated
// per row) and compiling on the spot when it is not. The source check is what makes
// the editor's live preview of an unsaved draft — same id, edited formula — use the
// draft rather than the stale compile.
func (c *CalcContext) formulaFor(uc UserCalculatedColumn) (*Formula, error) {
if f, ok := c.shared.compiled[uc.ID]; ok && f.src == uc.Formula {
return f, nil
}
return CompileFormula(uc.Formula)
}
// ---- computing and formatting a user's column / summary ----
// ComputeCalculatedColumn evaluates a calculated column for the context's current
// row (use ctx.ForRow). The result is NaN when it is not computable, with the
// first fault — a cycle, an unknown column, a division by zero — returned
// alongside; the display path ignores the error and renders CalcEmptyValue.
func ComputeCalculatedColumn(uc UserCalculatedColumn, ctx *CalcContext) (float64, error) {
// Seed the visiting set with this column, so a formula that names itself is a
// cycle on the first hop rather than the second.
return ctx.withVisiting(uc.ID).computeCalc(uc)
}
// FormatCalculatedColumn is ComputeCalculatedColumn plus the column's formatting —
// the text a cell, a CSV field and a PDF cell all show.
func FormatCalculatedColumn(uc UserCalculatedColumn, ctx *CalcContext) string {
n, _ := ComputeCalculatedColumn(uc, ctx)
return FormatCalcResult(n, uc.DataType, uc.Precision, "", "", "")
}
// ComputeSummaryRow evaluates a footer summary over the context's rows. There is
// no current row, so a custom formula's [cell] references and ROW() are NaN and
// only whole-column aggregates mean anything; a basic summary aggregates
// Operands[0] down the rows.
func ComputeSummaryRow(s UserSummaryRow, ctx *CalcContext) (float64, error) {
base := ctx.ForRow(NoRow)
if s.Fn == CALC_FN_CUSTOM || s.Fn == "" {
return EvalFormula(s.Formula, base)
}
if len(s.Operands) == 0 || s.Operands[0] == "" {
return math.NaN(), fmt.Errorf("%w: summary %q has no operand", ErrUnknownColumn, s.Label)
}
col, err := base.resolveColumn(s.Operands[0])
if err != nil {
return math.NaN(), err
}
return ApplyCalcFunction(s.Fn, col), nil
}
// FormatSummaryRow is ComputeSummaryRow plus formatting.
func FormatSummaryRow(s UserSummaryRow, ctx *CalcContext) string {
n, _ := ComputeSummaryRow(s, ctx)
return FormatCalcResult(n, s.DataType, s.Precision, "", "", "")
}
// FormatCalcResult renders a computed number per its data type. A result that is
// not a finite number (an empty column, a division by zero, a broken formula)
// renders as emptyValue, defaulting to CalcEmptyValue.
//
// precision is nil when unset, which only the "number" and "plain" types care
// about: they then format the value naturally rather than to a fixed width.
// prefix/suffix wrap the formatted text (the "$" of money and the "%" of percent
// are added inside them, so a prefix still leads).
func FormatCalcResult(result float64, dataType CalculatedDataType, precision *int, prefix, suffix, emptyValue string) string {
if math.IsNaN(result) || math.IsInf(result, 0) {
if emptyValue == "" {
return CalcEmptyValue
}
return emptyValue
}
money, percent := "", ""
var text string
switch dataType {
case CALC_TYPE_MONEY:
money = "$"
text = FormatDecimal(result, precisionOr(precision, 2))
case CALC_TYPE_DECIMAL:
text = FormatDecimal(result, precisionOr(precision, 2))
case CALC_TYPE_INTEGER:
text = FormatNumber(calcRound(result))
case CALC_TYPE_PERCENT:
percent = "%"
text = calcToFixed(result, precisionOr(precision, 2)) // no thousands grouping, as in the TSX
case CALC_TYPE_NUMBER:
if precision != nil {
text = FormatDecimal(result, *precision)
} else {
text = FormatNumber(result)
}
default: // CALC_TYPE_PLAIN, and anything unset
if precision != nil {
text = calcToFixed(result, *precision)
} else {
text = stringify(result)
}
}
// Never render a negative zero: rounded to the display precision, a value like
// SIN(2*PI) (about -2.4e-16) would otherwise show as "-0" / "-0.00".
if strings.HasPrefix(text, "-") && !strings.ContainsAny(text, "123456789") {
text = text[1:]
}
return prefix + money + text + percent + suffix
}
func precisionOr(precision *int, fallback int) int {
if precision == nil {
return fallback
}
return *precision
}
// calcToFixed is JavaScript's Number.toFixed: a fixed number of decimals, no
// thousands separators.
//
// Divergence, minor: Go rounds a tie to even on the exact binary value where
// JavaScript rounds it away from zero, so (2.5).toFixed(0) is "3" there and "2"
// here. It only bites on values that are exactly representable halves, and it is
// how the rest of this package already rounds (see formattersFormatGrouped).
func calcToFixed(v float64, digits int) string {
if digits < 0 {
digits = 0
}
return strconv.FormatFloat(v, 'f', digits, 64)
}
// ==========================================================================
// Export: CSV, PDF, print
// ==========================================================================
// AutoTable's export path: CSV, PDF, and Print — the port of downloadCSV /
// buildTablePDF / downloadPDF / handlePrintPDF from AutoTable.tsx.
//
// The one rule that matters: an export writes what the filter SELECTED, not what
// happens to be on screen. The TSX achieved that by re-running its pipeline with
// MaxItemsPerPage: -1; here it is AutoTableState.FilteredRows(), which is already
// every matching row across all pages. Exporting the current page would be a bug
// the user only discovers in the spreadsheet.
//
// Bytes leave wasm through wasmruntime.Download / wasmruntime.Print, which are
// no-ops on the server — so an SSR render that touches this code path renders,
// rather than panicking.
// exportColumns keeps the columns an export actually covers: CSV set, and some
// way to produce a value — an explicit CSVValue, or a field to read via
// SortIdentifier. A column with CSV set but neither is silently dropped rather
// than emitted as a column of blanks (this mirrors the TSX, which required
// csvValue or a calculated spec).
func exportColumns(cols []AutoTableColumn) []AutoTableColumn {
out := make([]AutoTableColumn, 0, len(cols))
for _, c := range cols {
if c.CSV && (c.CSVValue != nil || c.SortIdentifier != "") {
out = append(out, c)
}
}
return out
}
// exportValue is a column's text for one row: CSVValue if it has one, else the
// field named by SortIdentifier, rendered the way the table renders it.
//
// The rendered cell is a *vdom.VNode, so an export can never reuse it — which is
// exactly why AutoTableColumn carries CSVValue at all.
// rowIndex is the row's position in the exported set. A calculated column's value
// can depend on it (a running total, ROW()), which is why CSVValueAt exists — the
// index-free CSVValue cannot express one.
func exportValue(col AutoTableColumn, row any, rowIndex int, read FieldReader) string {
if col.CSVValueAt != nil {
return col.CSVValueAt(row, rowIndex)
}
if col.CSVValue != nil {
return col.CSVValue(row)
}
if read == nil {
read = DefaultFieldReader
}
return stringify(read(row, col.SortIdentifier))
}
// ---- CSV ----
// ExportCSV renders the rows as a CSV file (header row + one line per row),
// covering the columns with CSV set. Returns nil when there is nothing to export
// — no export columns, or no rows — so a caller can skip the download entirely.
//
// Quoting is RFC 4180 via encoding/csv: a field containing a comma, a quote, a
// newline, or a leading space is wrapped in quotes and its own quotes doubled.
// Records are separated by "\n" (not CRLF): that is what the TSX emitted, and
// every spreadsheet reads it.
//
// On kjol/csv: it exists, and MakeCSV does the same quoting (it wraps
// encoding/csv too) — but it is not reused here. Two reasons. It lives in a
// package that imports net/http for WriteCSVtoHTTP, and importing it would drag
// the HTTP stack into every WebAssembly bundle that renders a table, for twenty
// lines of buffer plumbing. And it has no formula-injection guard, which an
// export destined for a spreadsheet needs.
func ExportCSV(cols []AutoTableColumn, rows []any, read FieldReader) []byte {
return exportCSV(cols, rows, read, true)
}
func exportCSV(cols []AutoTableColumn, rows []any, read FieldReader, guard bool) []byte {
cols = exportColumns(cols)
if len(cols) == 0 || len(rows) == 0 {
return nil
}
cell := csvSafe
if !guard {
cell = func(s string) string { return s }
}
var buf bytes.Buffer
w := csv.NewWriter(&buf)
rec := make([]string, len(cols))
for i, c := range cols {
rec[i] = cell(c.DisplayName)
}
w.Write(rec) //nolint:errcheck // a bytes.Buffer cannot fail
for rowIdx, row := range rows {
for i, c := range cols {
rec[i] = cell(exportValue(c, row, rowIdx, read))
}
w.Write(rec) //nolint:errcheck
}
w.Flush()
return buf.Bytes()
}
// ExportCSVRaw is ExportCSV with the formula-injection guard OFF: cell values are
// written exactly as they are, with no leading quote.
//
// Use it only when the file is consumed by a program rather than opened in a
// spreadsheet — a downstream parser sees the guard's apostrophe as part of the
// value, whereas Excel and Sheets hide it. Do NOT use it for a file a person will
// open: that is precisely the injection ExportCSV exists to stop. See csvSafe.
func ExportCSVRaw(cols []AutoTableColumn, rows []any, read FieldReader) []byte {
return exportCSV(cols, rows, read, false)
}
// csvSafe defuses CSV formula injection: a spreadsheet treats a cell beginning
// with =, +, -, @, tab or CR as a FORMULA, so a row of user-supplied data reading
// `=cmd|'/c calc'!A1` becomes code the moment someone opens the file. Prefixing a
// single quote makes the spreadsheet read it as text.
//
// The catch is the minus sign: blindly guarding it would rewrite every negative
// number in a financial table into a quoted string, which is worse than the
// disease. So a value that is merely a NUMBER dressed up as currency —
// "-1,234.50", "-$99", "-12.5%" — passes through untouched, and only a leading
// sigil on something that is NOT a number is escaped.
//
// Which does mean an E.164 phone number ("+1 (555) 010-9999") gets the quote: it
// is not a number, and a spreadsheet would try to evaluate that leading +. The
// quote is what makes it display as typed.
func csvSafe(s string) string {
if s == "" {
return s
}
switch s[0] {
case '=', '+', '-', '@', '\t', '\r':
default:
return s
}
if looksNumeric(s) {
return s
}
return "'" + s
}
// looksNumeric reports whether s is a number once the decoration a formatter adds
// (currency, grouping commas, percent signs, parens) is removed.
func looksNumeric(s string) bool {
// The decoration a money/percent formatter adds, plus ordinary and non-breaking
// spaces.
const strip = "$,%()  "
stripped := strings.Map(func(r rune) rune {
if strings.ContainsRune(strip, r) {
return -1
}
return r
}, s)
if stripped == "" {
return false
}
_, err := strconv.ParseFloat(stripped, 64)
return err == nil
}
// DownloadCSV builds the CSV and hands it to the browser as a file. A no-op when
// there is nothing to export, and on the server (wasmruntime.Download is a no-op
// there), so it is safe to call from anywhere.
func DownloadCSV(filename string, cols []AutoTableColumn, rows []any, read FieldReader) {
data := ExportCSV(cols, rows, read)
if len(data) == 0 {
return
}
wasmruntime.Download(withExt(filename, ".csv"), "text/csv;charset=utf-8;", data)
}
// withExt appends ext (lower-case, dotted) unless name already carries it.
func withExt(name, ext string) string {
name = strings.TrimSpace(name)
if name == "" {
name = "export"
}
if strings.HasSuffix(strings.ToLower(name), ext) {
return name
}
return name + ext
}
// ---- PDF ----
// AutoTablePDFHeader is the PDF's cover matter: what is printed above the table on
// the first page, and how the page is turned. It is the port of the TSX's
// AutoTablePDFHeader.
//
// Two deliberate divergences from the TSX:
//
// - showDate defaulted to TRUE there. A Go zero value cannot default to true, so
// ShowDate is opt-in. Set it.
// - logoUrl / showLogo are GONE. Drawing a logo means decoding a PNG or JPEG and
// embedding it as an image XObject; the PDF writer here does not do images at
// all (see pdf.go). Rather than silently drop the field, it is not offered.
// BelowTable is the escape hatch for anything else you want on the page.
type AutoTablePDFHeader struct {
Title string
Subtitle string
// ShowDate prints the date at the top right of the first page.
ShowDate bool
// Date overrides "today" — mostly so an export is reproducible in a test.
Date time.Time
Orientation PDFOrientation
// Summaries are footer rows (Total, Subtotal, …) drawn in a shaded band under
// the table: the label right-aligned in the second-to-last column, the value in
// the last, mirroring the on-screen <tfoot>.
Summaries []AutoTablePDFSummary
// BelowTable draws whatever the app wants under the table — the TSX's escape
// hatch, kept. It runs after the summaries, with the cursor where the table
// left it.
BelowTable func(PDFBelowTableContext)
}
// AutoTablePDFSummary is one footer summary line.
type AutoTablePDFSummary struct{ Label, Value string }
// PDFBelowTableContext is what BelowTable gets: the document, where the table
// ended, the page geometry, and the palette the table itself used — so anything
// drawn underneath matches it.
type PDFBelowTableContext struct {
PDF *PDF
// Y is the current cursor: the baseline-agnostic top edge of whatever comes
// next, in PDF coordinates (y grows UP, so subtract as you descend).
Y float64
PageWidth, PageHeight float64
Margin, ContentWidth float64
Text, Muted, Border PDFColor
// AddPage starts a fresh page and returns the Y to continue from.
AddPage func() float64
}
// PDF layout, in points. Copied from the TSX so the two exports line up.
const (
pdfMargin = 40.0
pdfRowHeight = 18.0
pdfColHeaderHeight = 22.0
pdfFontSize = 8.0
pdfColHeaderFontSize = 9.0
pdfTitleSize = 12.0
pdfSubtitleSize = 9.0
pdfDateSize = 9.0
pdfCellPad = 4.0
)
// The palette, likewise.
var (
pdfTextColor = PDFGray(0.1)
pdfMutedColor = PDFGray(0.5)
pdfBorderColor = PDFGray(0.8)
pdfRowAltColor = PDFGray(0.95)
pdfRuleColor = PDFGray(0.15)
pdfSummaryBand = PDFGray(0.96)
pdfSummaryDivider = PDFGray(0.55)
)
// ExportPDF renders the rows as a paginated PDF table: the header row repeats on
// every page, rows zebra-stripe, and each page is footed with "Page n of m".
// Returns nil when there is nothing to export.
//
// Column widths are MEASURED, not guessed from character counts as the TSX did:
// each column's weight is the widest thing it has to hold (its header at the
// header size, or any cell at the body size), and the weights are then scaled to
// fill the content width. Text that still does not fit its column is truncated
// with an ellipsis — measured too, so it really does fit.
//
// Cells honor the column's DisplayPosition, so a right-aligned money column is
// right-aligned in the PDF as well. (The TSX left-aligned everything; this is the
// whole point of carrying a width table.)
func ExportPDF(cols []AutoTableColumn, rows []any, read FieldReader, h AutoTablePDFHeader) []byte {
cols = exportColumns(cols)
if len(cols) == 0 || len(rows) == 0 {
return nil
}
pdf := NewPDF(PDFOptions{Orientation: h.Orientation})
contentWidth := pdf.Width() - 2*pdfMargin
widths := pdfColumnWidths(cols, rows, read, contentWidth)
// colX[i] is the left edge of column i; the last entry is the right edge of the
// table, which is what a right-aligned cell measures back from.
colX := make([]float64, len(cols)+1)
colX[0] = pdfMargin
for i, w := range widths {
colX[i+1] = colX[i] + w
}
body := PDFTextStyle{Size: pdfFontSize, Color: pdfTextColor}
head := PDFTextStyle{Size: pdfColHeaderFontSize, Bold: true, Color: pdfTextColor}
y := 0.0
firstPage := true
// drawCell writes one cell's text, truncated to the column and aligned the way
// the column is aligned on screen.
drawCell := func(i int, baseline float64, text string, st PDFTextStyle) {
w := widths[i]
text = PDFTruncate(text, w-2*pdfCellPad, st.Size, st.Bold)
if text == "" {
return
}
switch cols[i].DisplayPosition {
case COL_POS_RIGHT:
pdf.TextRight(colX[i+1]-pdfCellPad, baseline, text, st)
case COL_POS_CENTER:
pdf.TextCenter(colX[i]+w/2, baseline, text, st)
default:
pdf.Text(colX[i]+pdfCellPad, baseline, text, st)
}
}
// drawPageHeader is the title block: title left, subtitle under it, date right.
// First page only — a report's title on page 7 is noise.
drawPageHeader := func() {
if h.Title == "" && h.Subtitle == "" && !h.ShowDate {
return
}
if h.ShowDate {
pdf.TextRight(pdfMargin+contentWidth, y-pdfDateSize-2, pdfDate(h),
PDFTextStyle{Size: pdfDateSize, Color: pdfMutedColor})
}
rightHeight := 0.0
if h.ShowDate {
rightHeight = pdfDateSize + 2
}
leftHeight := 0.0
if h.Title != "" {
pdf.Text(pdfMargin, y-pdfTitleSize, h.Title,
PDFTextStyle{Size: pdfTitleSize, Bold: true, Color: pdfTextColor})
leftHeight = pdfTitleSize + 6
}
if h.Subtitle != "" {
pdf.Text(pdfMargin, y-leftHeight-pdfSubtitleSize-4, h.Subtitle,
PDFTextStyle{Size: pdfSubtitleSize, Color: pdfMutedColor})
leftHeight += pdfSubtitleSize + 4
}
if block := max(rightHeight, leftHeight); block > 0 {
y -= block + 16
}
pdf.Line(pdfMargin, y, pdfMargin+contentWidth, y, 0.75, pdfBorderColor)
y -= 12
}
drawColumnHeaders := func() {
for i, c := range cols {
drawCell(i, y-pdfColHeaderHeight+7, c.DisplayName, head)
}
y -= pdfColHeaderHeight
pdf.Line(pdfMargin, y, pdfMargin+contentWidth, y, 1, pdfRuleColor)
}
addPage := func() {
pdf.AddPage()
y = pdf.Height() - pdfMargin
if firstPage {
drawPageHeader()
firstPage = false
}
drawColumnHeaders()
}
addPage()
for i, row := range rows {
// The bottom margin is the floor: a row that would cross it starts a new
// page, whose repeated header is what makes a multi-page table readable.
if y-pdfRowHeight < pdfMargin {
addPage()
}
if i%2 == 1 {
pdf.Rect(pdfMargin, y-pdfRowHeight, contentWidth, pdfRowHeight, pdfRowAltColor)
}
// Rules go BETWEEN rows. The table's closing rule is drawn once, after the
// loop — drawing one under the last row as well would put it at exactly the
// same y as the summary divider below, and two rules at one y read as a double
// border.
if i < len(rows)-1 {
pdf.Line(pdfMargin, y-pdfRowHeight, pdfMargin+contentWidth, y-pdfRowHeight, 0.5, pdfBorderColor)
}
for c := range cols {
drawCell(c, y-pdfRowHeight+6, exportValue(cols[c], row, i, read), body)
}
y -= pdfRowHeight
}
// The one rule that closes the table: heavier when summary rows follow, because
// then it is also the divider that separates them from the data.
if len(rows) > 0 {
if len(h.Summaries) > 0 {
pdf.Line(pdfMargin, y, pdfMargin+contentWidth, y, 1, pdfSummaryDivider)
} else {
pdf.Line(pdfMargin, y, pdfMargin+contentWidth, y, 0.5, pdfBorderColor)
}
}
if len(h.Summaries) > 0 {
last := len(cols) - 1
bold := PDFTextStyle{Size: pdfFontSize, Bold: true, Color: pdfTextColor}
for _, s := range h.Summaries {
if y-pdfRowHeight < pdfMargin {
addPage()
}
pdf.Rect(pdfMargin, y-pdfRowHeight, contentWidth, pdfRowHeight, pdfSummaryBand)
pdf.Line(pdfMargin, y-pdfRowHeight, pdfMargin+contentWidth, y-pdfRowHeight, 0.5, pdfBorderColor)
baseline := y - pdfRowHeight + 6
right := colX[last+1] - pdfCellPad
pdf.TextRight(right, baseline, s.Value, body)
if last > 0 {
// Label in the cell to the left of the value.
pdf.TextRight(colX[last]-pdfCellPad, baseline, s.Label, bold)
} else {
// Single-column table: both share the one cell.
pdf.TextRight(right-PDFTextWidth(s.Value, pdfFontSize, false)-8, baseline, s.Label, bold)
}
y -= pdfRowHeight
}
}
if h.BelowTable != nil {
h.BelowTable(PDFBelowTableContext{
PDF: pdf,
Y: y,
PageWidth: pdf.Width(),
PageHeight: pdf.Height(),
Margin: pdfMargin,
ContentWidth: contentWidth,
Text: pdfTextColor,
Muted: pdfMutedColor,
Border: pdfBorderColor,
AddPage: func() float64 {
pdf.AddPage()
y = pdf.Height() - pdfMargin
return y
},
})
}
// Footers last: "of m" is not knowable until every page exists, which is why
// PDF.SetPage exists at all. (The TSX drew the logo here too — see the type doc.)
n := pdf.PageCount()
foot := PDFTextStyle{Size: 8, Color: pdfMutedColor}
for i := 0; i < n; i++ {
pdf.SetPage(i)
pdf.Text(pdfMargin, pdfMargin-20, "Page "+strconv.Itoa(i+1)+" of "+strconv.Itoa(n), foot)
}
return pdf.Bytes()
}
// pdfDate is the date printed in the header — a fixed one if the caller supplied
// it (a reproducible export), else today.
func pdfDate(h AutoTablePDFHeader) string {
d := h.Date
if d.IsZero() {
d = time.Now()
}
return FormatDateLong(d)
}
// pdfColumnSample caps how many rows are measured when sizing columns. Measuring
// 100k rows to pick a width is a waste; the first few hundred are representative,
// and anything wider than its column gets truncated anyway.
const pdfColumnSample = 200
// pdfColumnWidths sizes the columns: each one's natural width (the widest of its
// header and its sampled cells, plus padding), scaled so the row exactly fills the
// content width.
func pdfColumnWidths(cols []AutoTableColumn, rows []any, read FieldReader, contentWidth float64) []float64 {
widths := make([]float64, len(cols))
total := 0.0
for i, c := range cols {
w := PDFTextWidth(c.DisplayName, pdfColHeaderFontSize, true)
for r := 0; r < len(rows) && r < pdfColumnSample; r++ {
if cw := PDFTextWidth(exportValue(c, rows[r], r, read), pdfFontSize, false); cw > w {
w = cw
}
}
w += 2 * pdfCellPad
// A floor, so a column of empty cells under a one-letter header does not
// collapse to a sliver.
w = max(w, 24)
widths[i] = w
total += w
}
if total <= 0 {
for i := range widths {
widths[i] = contentWidth / float64(len(widths))
}
return widths
}
scale := contentWidth / total
for i := range widths {
widths[i] *= scale
}
return widths
}
// DownloadPDF builds the PDF and hands it to the browser as a file.
func DownloadPDF(filename string, cols []AutoTableColumn, rows []any, read FieldReader, h AutoTablePDFHeader) {
data := ExportPDF(cols, rows, read, h)
if len(data) == 0 {
return
}
wasmruntime.Download(withExt(filename, ".pdf"), "application/pdf", data)
}
// PrintPDF builds the PDF and opens the browser's print dialog on it, without ever
// writing a file (wasmruntime.Print loads it into a hidden iframe).
func PrintPDF(cols []AutoTableColumn, rows []any, read FieldReader, h AutoTablePDFHeader) {
data := ExportPDF(cols, rows, read, h)
if len(data) == 0 {
return
}
wasmruntime.Print("application/pdf", data)
}
// ---- the controller's half ----
//
// These are the methods a toolbar button actually calls. They export
// FilteredRows() — every row matching the current filter, across every page — so
// what lands in the spreadsheet is what the user filtered, not the twenty-five
// rows they happened to be looking at.
//
// FilteredRows is resolved by Render, so these are meaningful only after the table
// has rendered once. In practice they hang off a button inside that table.
// ExportCSVBytes is the current filtered result set as a CSV file.
func (s *AutoTableState) ExportCSVBytes() []byte {
return ExportCSV(s.ExportColumns(), s.FilteredRows(), s.read)
}
// withSummaries fills in the PDF's footer lines from the table's OWN summary rows —
// including any the user built at runtime in the editor.
//
// Without this the caller had to restate them, which meant a summary row someone
// created showed up on screen and then quietly vanished from the export: the one
// number they most likely wanted in the file. They are evaluated here, against the
// same filtered rows the PDF is about to print, so the footer agrees with the table
// above it.
//
// A caller who passes Summaries explicitly keeps them — that is the escape hatch for
// a footer that is not one of the table's own rows.
func (s *AutoTableState) withSummaries(h AutoTablePDFHeader) AutoTablePDFHeader {
if len(h.Summaries) > 0 || len(s.SummaryRows()) == 0 {
return h
}
rows := s.FilteredRows()
ctx := NewCalcContext(rows, s.cols, s.Calculated(), s.read)
h.Summaries = make([]AutoTablePDFSummary, 0, len(s.SummaryRows()))
for _, sr := range s.SummaryRows() {
h.Summaries = append(h.Summaries, AutoTablePDFSummary{
Label: sr.Label,
Value: FormatSummaryRow(sr, ctx),
})
}
return h
}
// ExportPDFBytes is the current filtered result set as a PDF file.
func (s *AutoTableState) ExportPDFBytes(h AutoTablePDFHeader) []byte {
return ExportPDF(s.ExportColumns(), s.FilteredRows(), s.read, s.withSummaries(h))
}
// DownloadCSV downloads the current filtered result set as `filename`.csv.
func (s *AutoTableState) DownloadCSV(filename string) {
DownloadCSV(filename, s.ExportColumns(), s.FilteredRows(), s.read)
}
// DownloadPDF downloads the current filtered result set as `filename`.pdf.
func (s *AutoTableState) DownloadPDF(filename string, h AutoTablePDFHeader) {
DownloadPDF(filename, s.ExportColumns(), s.FilteredRows(), s.read, s.withSummaries(h))
}
// PrintPDF opens the print dialog on the current filtered result set.
func (s *AutoTableState) PrintPDF(h AutoTablePDFHeader) {
PrintPDF(s.ExportColumns(), s.FilteredRows(), s.read, s.withSummaries(h))
}