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