import { createSignal, createEffect, createMemo, onCleanup, untrack, on, For, Show, JSXElement, JSX } from "solid-js"; import { PDFDocument, StandardFonts, rgb } from "pdf-lib"; import { getDocument, GlobalWorkerOptions } from "pdfjs-dist"; // The worker reads import.meta.url, so it must load from a real URL (not a // Blob). The bundler's asset-url plugin emits it from frontend/vendor into // wwwroot/vendor and resolves this import to its served URL. import { FormInput, FormSelect, FormMultiSelect, FormMultiSelectTrigger, FormCombobox, FormLabel, type FormSelectOption } from "./Forms.tsx"; import { DatePicker } from "./DatePicker.tsx"; import {Icon} from "./Icons.tsx"; import { Menu, MenuContent, MenuDivider, MenuItem, MenuSection, MenuTrigger } from "./Menu.tsx"; import { Popover, PopoverContent, PopoverTrigger, HoverPopover, HoverPopoverTrigger, HoverPopoverContent } from "./Popovers.tsx"; import { useFloatingContext } from "./Floating.tsx"; import { BUTTON_COLOR_LIGHT_NEUTRAL, BUTTON_COLOR_PRIMARY, ButtonUI, ButtonLinkRed, SegmentedButtons } from "./Buttons.tsx"; import { authFetch } from "../auth/useAuthFetch.js"; import { compareRowsGeneric } from "./CellGrid.tsx"; import { accessor, readAccessor, type MaybeAccessor } from "../utils/accessors.ts"; import { formatNumber, formatDecimal } from "./Formatters.ts"; const pdfWorkerUrl = new URL("pdfjs-dist/build/pdf.worker.min.mjs", import.meta.url).toString(); if (!GlobalWorkerOptions.workerSrc) { GlobalWorkerOptions.workerSrc = pdfWorkerUrl; } export type ColumnPosition = 0 | 1 | 2; export type AutoTableHeaderColor = 0 | 1 | 2 | 3 | 4; export type AutoTableSize = 0 | 1 | 2; export type PDFOrientation = 0 | 1; // Predefined spreadsheet-style functions for calculated columns. A fixed // enumeration whose behavior is obvious and reviewable: "sum/average/min/max/ // count" ignore blank/non-numeric operands (like Excel's aggregate functions); // "subtract/multiply/divide" require every operand to resolve to a number, // otherwise the cell shows `emptyValue`. The "custom" function instead evaluates // an Excel-style `formula` expression (see compileFormula). export type CalculatedFunction = | "sum" | "subtract" | "multiply" | "divide" | "average" | "median" | "mode" | "min" | "max" | "count"; // How a calculated result is formatted for display/CSV/PDF. export type CalculatedDataType = "plain" | "number" | "integer" | "decimal" | "money" | "percent"; // A user-created calculated column, configured at runtime through the UI and // (optionally) persisted to localStorage. For predefined functions, `operands` // reference other columns by key — a data column's sortIdentifier, or // "_calc_" to reference another calculated column. For fn "custom", // `formula` holds an Excel-style expression that references columns as // [Display Name] (see compileFormula). export interface UserCalculatedColumn { id: string; displayName: string; fn: CalculatedFunction | "custom"; operands: string[]; formula?: string; dataType?: CalculatedDataType; precision?: number; displayPosition?: ColumnPosition; // header + cell alignment (default right) } // A user-created footer summary line (Total, Subtotal, …). Unlike a calculated // column, the formula is evaluated ONCE (no current row), so [cell] refs are NaN // and only whole-column aggregates like SUM({Revenue}) are meaningful. The single // result is shown right-aligned in the footer (rightmost column), with `label` to // its left. export interface UserSummaryRow { id: string; label: string; // Basic mode: a predefined fn aggregating `operands[0]` down the rows. // Advanced mode: fn "custom" with an Excel-style `formula`. fn?: CalculatedFunction | "custom"; operands?: string[]; formula?: string; dataType?: CalculatedDataType; precision?: number; } const CALC_DATATYPE_OPTIONS: { value: CalculatedDataType; label: string }[] = [ { value: "number", label: "Number" }, { value: "integer", label: "Integer" }, { value: "decimal", label: "Decimal" }, { value: "money", label: "Money" }, { value: "percent", label: "Percent" }, { value: "plain", label: "Plain" }, ]; // Basic-mode functions. For a calculated COLUMN these combine the chosen columns // per row (column-based); binary ones (subtract/divide) take two ordered columns, // the rest take a multi-select. For a SUMMARY they aggregate one column down the // rows (row-based), so only the aggregates apply. const CALC_FUNCTION_OPTIONS: { value: string; label: string }[] = [ { value: "sum", label: "Sum" }, { value: "subtract", label: "Subtract" }, { value: "multiply", label: "Multiply" }, { value: "divide", label: "Divide" }, { value: "average", label: "Average (mean)" }, { value: "median", label: "Median" }, { value: "mode", label: "Mode" }, { value: "min", label: "Minimum" }, { value: "max", label: "Maximum" }, { value: "count", label: "Count" }, ]; const SUMMARY_FUNCTION_OPTIONS: { value: string; label: string }[] = [ { value: "sum", label: "Sum" }, { value: "average", label: "Average (mean)" }, { value: "median", label: "Median" }, { value: "mode", label: "Mode" }, { value: "min", label: "Minimum" }, { value: "max", label: "Maximum" }, { value: "count", label: "Count" }, ]; const isBinaryCalcFn = (fn: string): boolean => fn === "subtract" || fn === "divide"; // Functions offered by the editor's "Function" menu. Names must match the // formula engine (see makeFormulaFunction); each is inserted as NAME() with the // caret placed between the parentheses. const FORMULA_FUNCTION_GROUPS: { label: string; fns: { name: string; sig: string; desc: string }[] }[] = [ { label: "Aggregate", fns: [ { name: "SUM", sig: "SUM(range)", desc: "Total of the values" }, { name: "AVERAGE", sig: "AVERAGE(range)", desc: "Mean of the values" }, { name: "MEDIAN", sig: "MEDIAN(range)", desc: "Middle value" }, { name: "MODE", sig: "MODE(range)", desc: "Most frequent value" }, { name: "MIN", sig: "MIN(range)", desc: "Smallest value" }, { name: "MAX", sig: "MAX(range)", desc: "Largest value" }, { name: "COUNT", sig: "COUNT(range)", desc: "How many numbers" }, ] }, { label: "Math", fns: [ { name: "ABS", sig: "ABS(n)", desc: "Absolute value" }, { name: "ROUND", sig: "ROUND(n, digits)", desc: "Round to digits" }, { name: "FLOOR", sig: "FLOOR(n)", desc: "Round down" }, { name: "CEILING", sig: "CEILING(n)", desc: "Round up" }, { name: "SQRT", sig: "SQRT(n)", desc: "Square root" }, { name: "POWER", sig: "POWER(n, p)", desc: "n to the power p" }, { name: "MOD", sig: "MOD(n, d)", desc: "Remainder of n ÷ d" }, { name: "EXP", sig: "EXP(n)", desc: "e to the power n" }, { name: "LN", sig: "LN(n)", desc: "Natural log (base e)" }, { name: "LOG", sig: "LOG(n, base)", desc: "Log, base 10 by default" }, ] }, { label: "Trigonometry", fns: [ { name: "SIN", sig: "SIN(angle)", desc: "Sine (radians)" }, { name: "COS", sig: "COS(angle)", desc: "Cosine (radians)" }, { name: "TAN", sig: "TAN(angle)", desc: "Tangent (radians)" }, { name: "ASIN", sig: "ASIN(n)", desc: "Inverse sine" }, { name: "ACOS", sig: "ACOS(n)", desc: "Inverse cosine" }, { name: "ATAN", sig: "ATAN(n)", desc: "Inverse tangent" }, { name: "ATAN2", sig: "ATAN2(x, y)", desc: "Angle of point (x, y)" }, { name: "SINH", sig: "SINH(n)", desc: "Hyperbolic sine" }, { name: "COSH", sig: "COSH(n)", desc: "Hyperbolic cosine" }, { name: "TANH", sig: "TANH(n)", desc: "Hyperbolic tangent" }, { name: "PI", sig: "PI()", desc: "π constant" }, { name: "RADIANS", sig: "RADIANS(deg)", desc: "Degrees → radians" }, { name: "DEGREES", sig: "DEGREES(rad)", desc: "Radians → degrees" }, ] }, { label: "Logic", fns: [ { name: "IF", sig: "IF(test, then, else)", desc: "Choose by condition" }, { name: "AND", sig: "AND(a, b, …)", desc: "True if all are true" }, { name: "OR", sig: "OR(a, b, …)", desc: "True if any are true" }, { name: "NOT", sig: "NOT(a)", desc: "Negate" }, ] }, { label: "Row", fns: [ { name: "ROW", sig: "ROW()", desc: "Current row number" }, ] }, ]; // Named mathematical constants — written bare in a formula (e.g. 2 * PI * [r]). const FORMULA_CONSTANTS: Record = { PI: Math.PI, E: Math.E, TAU: Math.PI * 2, PHI: (1 + Math.sqrt(5)) / 2, SQRT2: Math.SQRT2, }; const FORMULA_CONSTANT_OPTIONS: { name: string; desc: string }[] = [ { name: "PI", desc: "π ≈ 3.14159" }, { name: "E", desc: "Euler's number ≈ 2.71828" }, { name: "TAU", desc: "2π ≈ 6.28319" }, { name: "PHI", desc: "Golden ratio ≈ 1.61803" }, { name: "SQRT2", desc: "√2 ≈ 1.41421" }, ]; function makeCalcColumnId(): string { return "c" + Date.now().toString(36) + Math.floor(Math.random() * 0x1000000).toString(36); } export interface CalculatedColumnSpec { fn: CalculatedFunction | "custom"; // Operands are either a row field key (its raw value is parsed as a number, // tolerating "$", ",", "%" and whitespace) or a numeric literal constant. operands: (string | number)[]; dataType?: CalculatedDataType; // drives default formatting // Default display formatting (ignored when `format` is supplied): precision?: number; // fixed number of decimal places prefix?: string; // e.g. "$" suffix?: string; // e.g. "%" emptyValue?: string; // shown when the result isn't a finite number (default "—") // Full override of display/CSV/PDF text. Receives the computed number (which // may be NaN/Infinity) and the row. format?: (result: number, item: any) => any; // Overrides operand/fn computation. User columns set this to resolve // calc-in-calc references and custom formulas; see computeCalculatedValue. compute?: (item: any) => number; } export interface AutoTableColumn { displayName: string; displayPosition?: ColumnPosition; sortable?: boolean; sortIdentifier?: string; // sortType controls the local-sort comparator (see compareRowsGeneric): // "numeric" sorts by leading digits then suffix (so "4" < "33", "4NY" < "4X"), // "money" by parsed float, otherwise locale string compare. Only applies to // client-side (local) sorting; server-side sorting is driven by the backend. sortType?: string; // sortValue extracts the value to compare for this column when sorting // locally (e.g. a computed column). Defaults to row[sortIdentifier]. sortValue?: (item: any) => unknown; headerClasses?: string; csv?: boolean; csvValue?: (item: any) => any; toggleable?: boolean; hiddenByDefault?: boolean; // Marks this as a calculated column: AutoTable renders, exports, and sorts // its value itself from `calculated`, so your cellRenderer does NOT handle // it. Calculated columns require `cellRenderer` (not `rowRenderer`) because // the table needs per-cell control to inject the computed value, and are // evaluated client-side only. See computeCalculatedValue. calculated?: CalculatedColumnSpec; } export interface PDFBelowTableContext { page: any; y: number; pageWidth: number; pageHeight: number; margin: number; contentWidth: number; font: any; fontBold: any; colors: { text: any; muted: any; border: any }; response: any; pdf: any; addPage: () => { page: any; y: number }; } export interface AutoTablePDFHeader { title?: string; subtitle?: string; showDate?: boolean; logoUrl?: string; showLogo?: boolean; orientation?: PDFOrientation; belowTable?: (ctx: PDFBelowTableContext) => void | Promise; } export interface AutoTableOptions { size?: AutoTableSize; shadow?: boolean; hover?: boolean; alternate?: boolean; headerBorderY?: boolean; surroundingBorder?: boolean; borderX?: boolean; borderY?: boolean; color?: AutoTableHeaderColor; tableLayoutAuto?: boolean; hidePagination?: boolean; paginationShowAll?: boolean; draggableColumns?: boolean; columnOrderStorageKey?: string; toggleColumns?: boolean; columnVisibilityStorageKey?: string; // Let users drag the right edge of a column header to resize it. Works best // with the default fixed table layout (not tableLayoutAuto). resizableColumns?: boolean; // Persist user-set column widths (px, keyed by column) to localStorage. columnWidthStorageKey?: string; // Show an in-table Reset menu to restore column order/widths/calculated // columns (only the resets for enabled features are listed). resetButton?: boolean; exportCSV?: boolean; exportFilename?: string; accordion?: boolean; accordionSingle?: boolean; pdfHeader?: AutoTablePDFHeader; // Render `searchFields` as a card to the left of the table instead of inline // above it. The toolbar (export, column toggles, custom actions) stays on top. searchAside?: boolean; /** Place export/column toggles at the end of the inline filter row (desktop). */ inlineToolbar?: boolean; // Let end users add/edit/remove their own calculated columns at runtime via // a toolbar button. Calculated columns are appended after the data columns, // computed client-side from the table's numeric columns. calculatedColumns?: boolean; // Persist user-created calculated columns to localStorage under this key. calculatedColumnsStorageKey?: string; // Let end users add/edit/remove footer summary lines (Total, Subtotal, …) at // runtime via a toolbar button. Each is a formula evaluated once over the // filtered rows, shown in a aligned under the chosen column. summaryRows?: boolean; // Persist user-created summary rows to localStorage under this key. summaryRowsStorageKey?: string; // When true, the Export button opens a popover (instead of the menu) that // lets the user edit otherwise developer-fixed export settings — file name, // PDF title/subtitle, orientation, show date/logo — before downloading. userCustomizeExport?: boolean; } export interface AutoTableSearchEntry { Identifier: string; Values: string[]; Exact?: boolean; } export interface AutoTablePagination { Disabled?: boolean; CurrentPage: number; NextPage?: number; PreviousPage?: number; TotalPages: number; TotalItems: number; MaxItemsPerPage: number; ItemsThisPage?: number; ViewRangeLower: number; ViewRangeUpper: number; } export interface AutoTableOrderBy { Identifier: string; Descending: boolean; } export interface AutoTableFilter { Pagination: AutoTablePagination; OrderBy: AutoTableOrderBy; Search: AutoTableSearchEntry[]; } export interface SearchFieldsContext { getSearchValue: (identifier: string) => string; setSearchValue: (identifier: string, value: string) => void; setSearchValueExact: (identifier: string, value: string) => void; getSearchValues: (identifier: string) => string[]; setSearchValues: (identifier: string, values: string[]) => void; getMultiSearchValue: (fields: string[]) => string; setMultiSearchValue: (fields: string[], value: string) => void; resetColumnOrder: () => void; } export interface ToolbarActionsContext { allFilteredData: () => any[]; } export interface AutoTableProps { url?: string; data?: any[]; remoteFiltering?: boolean; requireAuth?: boolean; // Reading this prop inside the initial remote-fetch effect lets a parent force // a reload (e.g. after creating a row) by changing the tracked value. Passed as // a signal accessor, wrapProps unwraps it to its current value here — so we read // the property reactively rather than calling it. refreshSignal?: unknown; columns: AutoTableColumn[]; options?: AutoTableOptions; initialFilter?: Partial; accordionKey?: string; searchFields?: (ctx: SearchFieldsContext) => any; aboveTable?: any; belowTable?: any; emptyMessage?: string | (() => string); rowRenderer?: (item: any, position: number, rowIdx: number) => any; cellRenderer?: (item: any, col: AutoTableColumn, colIdx: number, position: number, rowIdx: number) => any; accordionRenderer?: (item: any, accordionData: any) => any; toolbarActions?: (ctx: ToolbarActionsContext) => any; /** Extra items rendered inside the Export dropdown (e.g. QuickBooks CSV). */ exportMenuItems?: (ctx: ToolbarActionsContext) => any; // Predicate that flags one or more rows for highlighting. When the // returned predicate matches an item that lives on a different page than // the one currently displayed, the table jumps to that page so the row is // visible. Re-evaluated whenever the predicate function reference changes. highlightMatch?: (item: any) => boolean; // Called whenever the effective filter (search/sort/pagination) changes, so // a parent can build features off the current query without re-deriving it // (e.g. fetching the full result set to seed an "open as queue" action). onFilterChange?: (filter: AutoTableFilter) => void; } export interface AutoTableSearchProps { label: string; placeholder?: string; value: string; onchange: (value: string) => void; class?: string; } export interface AutoTableMultiSearchProps { label?: string; placeholder?: string; value: string; onchange: (value: string) => void; class?: string; } const MULTI_SEARCH_PREFIX = "_multi_"; export function AutoTableSearch(props: AutoTableSearchProps) { const fieldCls = () => props.class || AUTOTABLE_SEARCH_FIELD; return
props.onchange(e.currentTarget.value)} />
; } export interface AutoTableDateSearchProps { label: string; identifier?: string; getSearchValue?: (identifier: string) => string; value?: MaybeAccessor; onchange: (value: string) => void; placeholder?: string; class?: string; } export function AutoTableDateSearch(props: AutoTableDateSearchProps) { const liveValue = () => { if (props.identifier != null && props.getSearchValue) { return props.getSearchValue(props.identifier); } return readAccessor(props.value, ""); }; const fieldCls = () => props.class || AUTOTABLE_DATE_SEARCH_FIELD; return (
); } export function AutoTableMultiSearch(props: AutoTableMultiSearchProps) { return
props.onchange(e.currentTarget.value)} />
; } function createMultiSearchIdentifier(fields: string[]): string { return MULTI_SEARCH_PREFIX + fields.join(","); } function isMultiSearchIdentifier(identifier: string): boolean { return identifier.startsWith(MULTI_SEARCH_PREFIX); } function parseMultiSearchFields(identifier: string): string[] { if (!isMultiSearchIdentifier(identifier)) return []; return identifier.slice(MULTI_SEARCH_PREFIX.length).split(","); } export const COL_POS_LEFT: ColumnPosition = 0; export const COL_POS_RIGHT: ColumnPosition = 1; export const COL_POS_CENTER: ColumnPosition = 2; // Alignment picker options for the calculated-column editor (value is the // stringified ColumnPosition; declared after the COL_POS_* consts). const CALC_POSITION_OPTIONS: { value: string; label: string }[] = [ { value: String(COL_POS_LEFT), label: "Left" }, { value: String(COL_POS_CENTER), label: "Center" }, { value: String(COL_POS_RIGHT), label: "Right" }, ]; export const AUTOTABLE_HEADER_COLOR_DEFAULT = 0; export const AUTOTABLE_HEADER_COLOR_BLUE = 1; export const AUTOTABLE_HEADER_COLOR_GREEN = 2; export const AUTOTABLE_HEADER_COLOR_GRAY = 3; export const AUTOTABLE_HEADER_COLOR_DARK_BLUE = 4; export const AUTOTABLE_SIZE_DEFAULT: AutoTableSize = 0; export const AUTOTABLE_SIZE_COMPACT: AutoTableSize = 1; export const AUTOTABLE_SIZE_SUPERCOMPACT: AutoTableSize = 2; export const PDF_ORIENTATION_LANDSCAPE: PDFOrientation = 0; export const PDF_ORIENTATION_PORTRAIT: PDFOrientation = 1; // -- Tailwind class maps --------------------------------------------- // Background + text colors per header color. export const HEADER_COLOR_CLS: Record = { [AUTOTABLE_HEADER_COLOR_DEFAULT]: "bg-neutral-50", [AUTOTABLE_HEADER_COLOR_BLUE]: "bg-sky-700 text-white", [AUTOTABLE_HEADER_COLOR_GREEN]: "bg-green-700 text-white", [AUTOTABLE_HEADER_COLOR_GRAY]: "bg-neutral-600 text-white", [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "bg-sky-900 text-white", }; // Sortable hover override per color (applied when `sortable`). const HEADER_SORT_HOVER_CLS: Record = { [AUTOTABLE_HEADER_COLOR_DEFAULT]: "hover:bg-neutral-300", [AUTOTABLE_HEADER_COLOR_BLUE]: "hover:bg-sky-500", [AUTOTABLE_HEADER_COLOR_GREEN]: "hover:bg-green-800", [AUTOTABLE_HEADER_COLOR_GRAY]: "hover:bg-neutral-500", [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "hover:bg-sky-800", }; // Header text weight/case styling per color. export const HEADER_TEXT_CLS: Record = { [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", }; // Drag-handle color (muted variant of header color). const HEADER_DRAG_CLS: Record = { [AUTOTABLE_HEADER_COLOR_DEFAULT]: "text-neutral-500", [AUTOTABLE_HEADER_COLOR_BLUE]: "text-white/70", [AUTOTABLE_HEADER_COLOR_GREEN]: "text-white/70", [AUTOTABLE_HEADER_COLOR_GRAY]: "text-white/70", [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "text-white/70", }; // Sort icon color (same as header text color). const HEADER_SORT_ICON_CLS: Record = { [AUTOTABLE_HEADER_COLOR_DEFAULT]: "text-black", [AUTOTABLE_HEADER_COLOR_BLUE]: "text-white", [AUTOTABLE_HEADER_COLOR_GREEN]: "text-white", [AUTOTABLE_HEADER_COLOR_GRAY]: "text-white", [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "text-white", }; // Th cell padding per table size. export const HEADER_PADDING_CLS: Record = { [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 cell padding per table size — applied via [&_td]: on the tbody. export const BODY_PADDING_CLS: Record = { [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", }; // Pagination padding per table size. const PAGINATION_PADDING_CLS: Record = { [AUTOTABLE_SIZE_DEFAULT]: "py-3 px-4", [AUTOTABLE_SIZE_COMPACT]: "py-1 px-4", [AUTOTABLE_SIZE_SUPERCOMPACT]: "py-1 px-4", }; // Hover background per color (applied to body rows when `hover` is on). const ROW_HOVER_CLS: Record = { [AUTOTABLE_HEADER_COLOR_DEFAULT]: "hover:bg-neutral-200", [AUTOTABLE_HEADER_COLOR_BLUE]: "hover:bg-sky-100", [AUTOTABLE_HEADER_COLOR_GREEN]: "hover:bg-green-100", [AUTOTABLE_HEADER_COLOR_GRAY]: "hover:bg-neutral-200", [AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "hover:bg-sky-100", }; // Alignment per ColumnPosition. export const POS_CLS: Record = { [COL_POS_LEFT]: "text-left", [COL_POS_RIGHT]: "text-right", [COL_POS_CENTER]: "text-center", }; // Header inner flex direction per position. export const HEADER_INNER_POS: Record = { [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 -- export const TBL_CONTAINER = "relative flex flex-col w-full h-full bg-white rounded-default overflow-hidden"; export const TBL_WRAPPER = "overflow-x-auto w-full"; export const TBL_BASE = "min-w-full"; export const HEADER_CONTENT = "transition-transform duration-150 ease-in-out"; export const HEADER_INNER_BASE = "flex justify-between gap-2 items-center"; const DRAG_HANDLE = "opacity-40 shrink-0"; const SORT_ICON_WRAP = "leading-none shrink-0 opacity-50"; const ACCORDION_TOGGLE_TH = "w-10 text-center text-neutral-400"; const ACCORDION_TOGGLE_TD = "w-10 text-center text-neutral-400"; const ACCORDION_ICON = "inline-block transition-transform duration-200"; const SKELETON = "h-4 bg-neutral-200 rounded-default animate-pulse"; const ERROR_CELL = "text-center text-red-600"; const EMPTY_CELL = "text-center text-neutral-500"; const PAGINATION_BASE = "flex justify-between items-center border-t border-neutral-300"; const PAGINATION_INFO = "hidden sm:flex items-center text-sm text-neutral-500"; const PAGINATION_CONTROLS = "flex items-center"; const PAGINATION_LABEL = "hidden sm:block text-sm text-neutral-500 mr-2"; const PAGINATION_PAGE = "text-sm text-neutral-500 px-3"; const PAGINATION_BTN = "p-1 min-h-9 text-sm font-normal leading-none bg-transparent border-0 cursor-pointer hover:bg-neutral-100 disabled:text-neutral-300 disabled:cursor-not-allowed disabled:hover:bg-transparent"; const TOOLBAR = "min-w-0 max-w-full mb-1.5 overflow-visible"; const TOOLBAR_MOBILE_BAR = "flex flex-col items-stretch gap-2 mb-2 min-w-0 max-w-full lg:hidden overflow-visible"; const TOOLBAR_DESKTOP_ROW = "flex flex-col gap-3 min-w-0 max-w-full lg:flex-row lg:flex-wrap lg:items-end lg:gap-4 overflow-visible"; const TOOLBAR_ACTIONS = "flex flex-wrap items-center gap-2 min-w-0 max-w-full shrink-0 lg:flex-nowrap lg:ml-auto lg:flex-[0_0_auto] lg:justify-end"; const TOOLBAR_ACTIONS_INLINE = "flex flex-wrap items-center gap-2 min-w-0 shrink-0 lg:flex-nowrap lg:flex-[0_0_auto] lg:justify-end"; const PANEL_ACTIONS_BASE = "w-full min-w-0 max-lg:flex max-lg:flex-wrap max-lg:items-center max-lg:justify-end max-lg:gap-2 max-lg:pt-2.5 max-lg:mt-0.5 max-lg:border-t max-lg:border-neutral-200"; const PANEL_ACTIONS_INLINE = PANEL_ACTIONS_BASE + " lg:flex lg:ml-auto lg:shrink-0 lg:flex-[0_0_auto] lg:self-end lg:w-auto lg:border-t-0 lg:pt-0 lg:mt-0"; const PANEL_ACTIONS_DEFAULT = PANEL_ACTIONS_BASE + " lg:contents"; // Mirrors a small light-neutral ButtonUI for use as a bare PopoverTrigger // }
No columns match.
; const functionMenu = () => Function
{ funcSearchInput = el; }} type="text" spellcheck="false" placeholder="Search functions…" value={funcSearch()} oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setFuncSearch(e.currentTarget.value)} class={MENU_SEARCH} />
{(g: { label: string; fns: { name: string; sig: string; desc: string }[] }) =>
{g.label}
{(f: { name: string; sig: string; desc: string }) => }
}
No functions match.
; const constantMenu = () => Constant
{ constSearchInput = el; }} type="text" spellcheck="false" placeholder="Search constants…" value={constSearch()} oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setConstSearch(e.currentTarget.value)} class={MENU_SEARCH} />
{(c: { name: string; desc: string }) => }
No constants match.
; const advancedEditor = () =>
{ overlay = el; }} class={FORMULA_OVERLAY_CLS} aria-hidden="true">
{columnMenu()} {functionMenu()} {constantMenu()}
; return
Formula
References
[Col]this row's cell {"{Col}"}the whole column {"{Col:n}"}value in row n {"{Col:a:b}"}rows a through b
Operators
{"+ - * /"}add, subtract, multiply, divide ^power (a to the b) %remainder (modulo) {"= <> < > <= >="}compare (yields 1 or 0)
setMode(v === "advanced")} />
{advancedEditor()}
; } interface CalculatedColumnFormProps { column: () => UserCalculatedColumn | null; // null = add mode operandOptions: () => FormSelectOption[]; onSave: (col: UserCalculatedColumn) => void; onRemove?: (id: string) => void; // Highlight columns in the table while editing (operand keys, or null/[]). onHighlight?: (keys: string[] | null) => void; } // Editor for a calculated column, rendered inside a Popover. Reads the floating // context to prefill when the popover opens and to close itself on save/cancel. // A column is defined by an Excel-style formula (see compileFormula); the insert // chips reference other columns as [Col]/{Col}, including other calculated // columns (but never this one). Saved columns always have fn "custom". function CalculatedColumnForm(props: CalculatedColumnFormProps) { const ctx = useFloatingContext(); const [name, setName] = createSignal(""); const [spec, setSpec] = createSignal({ fn: "sum", operands: [] as string[], formula: "" } as FormulaSpec); const [dataType, setDataType] = createSignal("number" as CalculatedDataType); const [decimals, setDecimals] = createSignal(""); const [position, setPosition] = createSignal(String(COL_POS_RIGHT)); const [errorMsg, setErrorMsg] = createSignal(""); let formulaApi: FormulaFieldApi | undefined; // wrapProps may deliver these as the unwrapped value or as a function. const column = () => typeof props.column === "function" ? (props.column as () => UserCalculatedColumn | null)() : (props.column as unknown as UserCalculatedColumn | null); const allOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]); // Exclude this column from its own reference choices. const operandOptions = () => { const ed = column(); const selfRef = ed ? "_calc_" + ed.id : null; return allOptions().filter(o => o.value !== selfRef); }; const highlight = (keys: string[] | null) => props.onHighlight && props.onHighlight(keys); // Prefill the form each time the popover opens (FormulaField is seeded via its api). createEffect(() => { if (!ctx.isOpen()) return; untrack(() => { const ed = column(); setName(ed?.displayName ?? ""); setDataType(ed?.dataType ?? "number"); setDecimals(ed?.precision != null ? String(ed.precision) : ""); setPosition(String(ed?.displayPosition ?? COL_POS_RIGHT)); setErrorMsg(""); formulaApi?.setSpec({ fn: ed?.fn ?? (ed?.formula ? "custom" : "sum"), operands: ed?.operands ?? [], formula: ed?.formula ?? "" }); }); }); // Live formula validation feedback (Advanced/custom only). const formulaError = createMemo(() => { const s = spec(); if (s.fn !== "custom" || !s.formula.trim()) return ""; try { compileFormula(s.formula); return ""; } catch (e) { return e instanceof Error ? e.message : "Invalid formula"; } }); const close = () => ctx.setIsOpen(false); const handleSave = () => { const trimmedName = name().trim(); if (!trimmedName) { setErrorMsg("Enter a column name."); return; } const s = spec(); let operands: string[] = []; let formulaText: string | undefined; if (s.fn === "custom") { formulaText = s.formula.trim(); if (!formulaText) { setErrorMsg("Enter a formula."); return; } try { compileFormula(formulaText); } catch (e) { setErrorMsg(e instanceof Error ? e.message : "Invalid formula"); return; } } else { operands = s.operands.filter(Boolean); if (isBinaryCalcFn(s.fn)) { if (operands.length < 2) { setErrorMsg("Pick both columns."); return; } } else if (operands.length < 1) { setErrorMsg("Pick at least one column."); return; } } const ed = column(); const decTrimmed = decimals().trim(); const precision = decTrimmed === "" ? undefined : parseInt(decTrimmed, 10); const col: UserCalculatedColumn = { id: ed?.id ?? makeCalcColumnId(), displayName: trimmedName, fn: s.fn as CalculatedFunction | "custom", operands, formula: formulaText, dataType: dataType(), precision: (precision === undefined || Number.isNaN(precision)) ? undefined : precision, displayPosition: parseInt(position(), 10) as ColumnPosition, }; // Close before onSave: editing replaces this column's object, which // disposes this popover's row, so closing afterward would target a // disposed floating context. close(); props.onSave(col); }; const handleRemove = () => { const ed = column(); close(); if (ed && props.onRemove) props.onRemove(ed.id); }; return
{column() ? "Edit calculation" : "New calculation"}
Column Name setName(e.currentTarget.value)} />
{ formulaApi = api; }} />

{formulaError()}

Data type setDataType(e.currentTarget.value as CalculatedDataType)}> {(d: { value: string; label: string }) => }
Decimals setDecimals(e.currentTarget.value)} />
Alignment setPosition(e.currentTarget.value)}> {(p: { value: string; label: string }) => }

{errorMsg()}

handleRemove()}>Remove
close()}>Cancel handleSave()}>Save
; } interface SummaryRowFormProps { row: () => UserSummaryRow | null; // null = add mode operandOptions: () => FormSelectOption[]; // display-under columns + formula refs onSave: (row: UserSummaryRow) => void; onRemove?: (id: string) => void; onHighlight?: (keys: string[] | null) => void; } // Editor for a footer summary line (Total, Subtotal, …): a label and a single-value // calculation (FormulaField). The value is always shown right-aligned in the footer. // Mirrors CalculatedColumnForm's lifecycle (prefill on open, close before onSave). function SummaryRowForm(props: SummaryRowFormProps) { const ctx = useFloatingContext(); const [label, setLabel] = createSignal(""); const [spec, setSpec] = createSignal({ fn: "sum", operands: [] as string[], formula: "" } as FormulaSpec); const [dataType, setDataType] = createSignal("number" as CalculatedDataType); const [decimals, setDecimals] = createSignal(""); const [errorMsg, setErrorMsg] = createSignal(""); let formulaApi: FormulaFieldApi | undefined; const row = () => typeof props.row === "function" ? (props.row as () => UserSummaryRow | null)() : (props.row as unknown as UserSummaryRow | null); const operandOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]) || []; const highlight = (keys: string[] | null) => props.onHighlight && props.onHighlight(keys); createEffect(() => { if (!ctx.isOpen()) return; untrack(() => { const ed = row(); setLabel(ed?.label ?? ""); setDataType(ed?.dataType ?? "number"); setDecimals(ed?.precision != null ? String(ed.precision) : ""); setErrorMsg(""); formulaApi?.setSpec({ fn: ed?.fn ?? (ed?.formula ? "custom" : "sum"), operands: ed?.operands ?? [], formula: ed?.formula ?? "" }); }); }); const formulaError = createMemo(() => { const s = spec(); if (s.fn !== "custom" || !s.formula.trim()) return ""; try { compileFormula(s.formula); return ""; } catch (e) { return e instanceof Error ? e.message : "Invalid formula"; } }); const close = () => ctx.setIsOpen(false); const handleSave = () => { const trimmedLabel = label().trim(); if (!trimmedLabel) { setErrorMsg("Enter a label."); return; } const s = spec(); let operands: string[] = []; let formulaText: string | undefined; if (s.fn === "custom") { formulaText = s.formula.trim(); if (!formulaText) { setErrorMsg("Enter a formula."); return; } try { compileFormula(formulaText); } catch (e) { setErrorMsg(e instanceof Error ? e.message : "Invalid formula"); return; } } else { operands = s.operands.filter(Boolean); if (operands.length < 1) { setErrorMsg("Pick a column."); return; } } const ed = row(); const decTrimmed = decimals().trim(); const precision = decTrimmed === "" ? undefined : parseInt(decTrimmed, 10); const out: UserSummaryRow = { id: ed?.id ?? makeCalcColumnId(), label: trimmedLabel, fn: s.fn as CalculatedFunction | "custom", operands, formula: formulaText, dataType: dataType(), precision: (precision === undefined || Number.isNaN(precision)) ? undefined : precision, }; close(); props.onSave(out); }; const handleRemove = () => { const ed = row(); close(); if (ed && props.onRemove) props.onRemove(ed.id); }; return
{row() ? "Edit summary" : "New summary"}
Label setLabel(e.currentTarget.value)} />
{ formulaApi = api; }} />

{formulaError()}

Data type setDataType(e.currentTarget.value as CalculatedDataType)}> {(d: { value: string; label: string }) => }
Decimals setDecimals(e.currentTarget.value)} />

{errorMsg()}

handleRemove()}>Remove
close()}>Cancel handleSave()}>Save
; } interface AddCalcMenuProps { allowColumn: boolean; allowSummary: boolean; operandOptions: () => FormSelectOption[]; onSaveColumn: (col: UserCalculatedColumn) => void; onSaveSummary: (row: UserSummaryRow) => void; onHighlight?: (keys: string[] | null) => void; } // Content of the single toolbar "Add Calculation" popover: a chooser listing the // enabled options (calculated column and/or summary row), which then swaps to the // chosen editor in the same popover (so both editors share this floating context). // The chooser is always shown and resets when the popover closes. function AddCalcMenu(props: AddCalcMenuProps) { const ctx = useFloatingContext(); const allowColumn = () => typeof props.allowColumn === "function" ? (props.allowColumn as () => boolean)() : props.allowColumn; const allowSummary = () => typeof props.allowSummary === "function" ? (props.allowSummary as () => boolean)() : props.allowSummary; // Forward as an accessor (a function) so the binding stays reactive — passing // props.operandOptions directly would snapshot the array (losing new columns). const operandOptions = () => typeof props.operandOptions === "function" ? (props.operandOptions as () => FormSelectOption[])() : (props.operandOptions as unknown as FormSelectOption[]); const [view, setView] = createSignal("menu"); createEffect(() => { if (!ctx.isOpen()) setView("menu"); }); const ITEM = "w-full text-left px-2 py-2 rounded-default hover:bg-neutral-100 cursor-pointer border-0 bg-transparent flex items-start gap-2.5"; return <>
null} operandOptions={operandOptions} onSave={props.onSaveColumn} onHighlight={props.onHighlight} /> null} operandOptions={operandOptions} onSave={props.onSaveSummary} onHighlight={props.onHighlight} /> ; } /** * Feature-rich data table with sorting, pagination, search, drag-to-reorder columns, * column visibility toggle, CSV export, accordion rows, and user-defined * calculated columns. * Supports both local data and remote data fetching with optional server-side filtering. */ export function AutoTable(props: AutoTableProps) { const isRemote = () => !!props.url; const isLocalFiltering = () => !isRemote() || !props.remoteFiltering; const opts = createMemo(() => { const merged = { size: AUTOTABLE_SIZE_DEFAULT, shadow: false, hover: false, alternate: false, headerBorderY: false, surroundingBorder: false, borderX: false, borderY: false, color: AUTOTABLE_HEADER_COLOR_DEFAULT, tableLayoutAuto: false, hidePagination: false, paginationShowAll: false, draggableColumns: false, columnOrderStorageKey: undefined, toggleColumns: false, columnVisibilityStorageKey: undefined, resizableColumns: false, columnWidthStorageKey: undefined, resetButton: false, exportCSV: false, exportFilename: "export", pdfHeader: undefined, accordion: false, accordionSingle: false, searchAside: false, calculatedColumns: false, calculatedColumnsStorageKey: undefined, summaryRows: false, summaryRowsStorageKey: undefined, userCustomizeExport: false, ...props.options, }; if (merged.inlineToolbar === undefined && props.searchFields && !merged.searchAside) { merged.inlineToolbar = !!( merged.exportCSV || merged.toggleColumns || props.exportMenuItems || props.toolbarActions ); } return merged; }); const accordionKey = () => props.accordionKey ?? "accordion"; const [expandedRows, setExpandedRows] = createSignal(new Set() as Set); const toggleAccordion = (rowKey: any) => { setExpandedRows(prev => { const next = new Set(prev); if (next.has(rowKey)) { next.delete(rowKey); } else { if (opts().accordionSingle) { next.clear(); } next.add(rowKey); } return next; }); }; const [remoteData, setRemoteData] = createSignal([] as any[]); const [initialLoading, setInitialLoading] = createSignal(isRemote()); const [showLoading, setShowLoading] = createSignal(false); const [error, setError] = createSignal(null as string | null); const [isExporting, setIsExporting] = createSignal(false); // User-editable export settings (used when opts().userCustomizeExport). Seeded // from the developer-provided defaults; persist for the table's lifetime. const [exportFilenameInput, setExportFilenameInput] = createSignal(opts().exportFilename); const [pdfTitle, setPdfTitle] = createSignal(opts().pdfHeader?.title ?? ""); const [pdfSubtitle, setPdfSubtitle] = createSignal(opts().pdfHeader?.subtitle ?? ""); const [pdfOrientation, setPdfOrientation] = createSignal(String(opts().pdfHeader?.orientation ?? PDF_ORIENTATION_LANDSCAPE)); const [pdfShowDate, setPdfShowDate] = createSignal(opts().pdfHeader?.showDate ?? true); const effectiveExportFilename = () => opts().userCustomizeExport ? (exportFilenameInput().trim() || "export") : opts().exportFilename; const effectivePdfHeader = (): AutoTablePDFHeader | undefined => { if (!opts().userCustomizeExport) return opts().pdfHeader; // showLogo and logoUrl stay developer-controlled (not user-editable). return { ...opts().pdfHeader, title: pdfTitle(), subtitle: pdfSubtitle(), orientation: parseInt(pdfOrientation(), 10) as PDFOrientation, showDate: pdfShowDate(), }; }; const [draggedColumn, setDraggedColumn] = createSignal(null as number | null); const [dragOverColumn, setDragOverColumn] = createSignal(null as number | null); const [draggedSummary, setDraggedSummary] = createSignal(null as number | null); const [dragOverSummary, setDragOverSummary] = createSignal(null as number | null); // -- Resizable columns ---------------------------------------------------- // Widths (px) keyed by the same stable keys as the order system // ("d" for data columns, "_calc_" for calc columns). const loadColumnWidths = (): Record => { const key = opts().columnWidthStorageKey; if (key) { try { const stored = localStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored); if (parsed && typeof parsed === "object") return parsed; } } catch { // Ignore localStorage errors } } return {}; }; const [columnWidths, setColumnWidths] = createSignal(loadColumnWidths()); const [resizingColumn, setResizingColumn] = createSignal(null as string | null); createEffect(() => { const key = opts().columnWidthStorageKey; if (key && opts().resizableColumns) { try { localStorage.setItem(key, JSON.stringify(columnWidths())); } catch { // Ignore localStorage errors } } }); const MIN_COLUMN_WIDTH = 56; // Dragging a column boundary trades width between that column and its right // neighbor, keeping the total constant. This keeps the table within its // container (no runaway horizontal overflow) while still letting the user // set the proportions — the intuitive spreadsheet "move the boundary" feel. const startColumnResize = (e: MouseEvent, key: string) => { e.preventDefault(); e.stopPropagation(); const th = (e.currentTarget as HTMLElement).closest("th") as HTMLElement | null; const row = th?.parentElement; const startX = e.clientX; // Pin EVERY column to its current rendered width so the layout is fully // determined; without this the unsized columns reflow when one changes. const snapshot: Record = { ...columnWidths() }; const ths: HTMLElement[] = row ? Array.from(row.querySelectorAll("th[data-colkey]")) as HTMLElement[] : []; ths.forEach((el) => { const k = el.getAttribute("data-colkey"); if (k) snapshot[k] = el.getBoundingClientRect().width; }); setColumnWidths(snapshot); setResizingColumn(key); const startWidth = snapshot[key] ?? (th ? th.getBoundingClientRect().width : 120); // The right neighbor absorbs the change (the last column has no handle, so // a neighbor always exists). Falls back to a plain grow if none is found. const idx = ths.findIndex((el) => el.getAttribute("data-colkey") === key); const nextKey = idx >= 0 && idx + 1 < ths.length ? ths[idx + 1].getAttribute("data-colkey") : null; const startNextWidth = nextKey ? (snapshot[nextKey] ?? 0) : 0; const onMove = (ev: MouseEvent) => { const rawDelta = ev.clientX - startX; if (nextKey) { // Clamp so neither the dragged column nor its neighbor drops below // the minimum; the pair's combined width never changes. const delta = Math.max( MIN_COLUMN_WIDTH - startWidth, Math.min(startNextWidth - MIN_COLUMN_WIDTH, rawDelta), ); const newWidth = Math.round(startWidth + delta); const newNext = startWidth + startNextWidth - newWidth; setColumnWidths(prev => ({ ...prev, [key]: newWidth, [nextKey]: newNext })); } else { const newWidth = Math.max(MIN_COLUMN_WIDTH, Math.round(startWidth + rawDelta)); setColumnWidths(prev => ({ ...prev, [key]: newWidth })); } }; const onUp = () => { document.removeEventListener("mousemove", onMove); document.removeEventListener("mouseup", onUp); setResizingColumn(null); }; document.addEventListener("mousemove", onMove); document.addEventListener("mouseup", onUp); }; // Inline width style for a header cell, or undefined when unset. const columnWidthStyle = (key: string) => { const w = columnWidths()[key]; return w ? { width: w + "px" } : undefined; }; const getInitialVisibleColumns = () => { if (opts().columnVisibilityStorageKey) { try { const stored = localStorage.getItem(opts().columnVisibilityStorageKey); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) { return parsed; } } } catch { // Ignore localStorage errors } } return props.columns .map((col, i) => ({ col, i })) .filter(({ col }) => !col.hiddenByDefault) .map(({ i }) => i.toString()); }; const [visibleColumns, setVisibleColumns] = createSignal(getInitialVisibleColumns()); createEffect(on( () => props.columns.length, () => { const allIndices = props.columns.map((_, i) => i.toString()); const validVisible = visibleColumns().filter(v => allIndices.includes(v)); const newColumns = allIndices.filter(i => !visibleColumns().includes(i) && !props.columns[parseInt(i)]?.hiddenByDefault); if (newColumns.length > 0 || validVisible.length !== visibleColumns().length) { setVisibleColumns([...validVisible, ...newColumns]); } } )); createEffect(() => { if (opts().columnVisibilityStorageKey && opts().toggleColumns) { try { localStorage.setItem(opts().columnVisibilityStorageKey, JSON.stringify(visibleColumns())); } catch { // Ignore localStorage errors } } }); // -- User-created calculated columns ------------------------------------- const loadUserCalcColumns = (): UserCalculatedColumn[] => { const key = opts().calculatedColumnsStorageKey; if (key) { try { const stored = localStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) return parsed; } } catch { // Ignore localStorage errors } } return []; }; const [userCalcColumns, setUserCalcColumns] = createSignal(loadUserCalcColumns()); createEffect(() => { const key = opts().calculatedColumnsStorageKey; if (key && opts().calculatedColumns) { try { localStorage.setItem(key, JSON.stringify(userCalcColumns())); } catch { // Ignore localStorage errors } } }); const CALC_REF_PREFIX = "_calc_"; // Lookups rebuilt whenever the set of calc columns changes. const calcById = createMemo(() => { const m = new Map(); for (const c of userCalcColumns()) m.set(c.id, c); return m; }); // Maps a referenceable display name (lowercased) to an operand key, so custom // formulas can use [Display Name]. Data columns resolve to their // sortIdentifier; calc columns to "_calc_". const calcNameToRef = createMemo(() => { const m = new Map(); for (const col of props.columns) if (col.sortIdentifier) m.set(col.displayName.trim().toLowerCase(), col.sortIdentifier); for (const c of userCalcColumns()) m.set(c.displayName.trim().toLowerCase(), CALC_REF_PREFIX + c.id); return m; }); // Compile custom formulas once per change (not per row); invalid -> null. const compiledFormulas = createMemo(() => { const m = new Map(); for (const c of userCalcColumns()) { if (c.fn === "custom") { try { m.set(c.id, compileFormula(c.formula ?? "")); } catch { m.set(c.id, null); } } } return m; }); // Build the per-row resolvers a formula needs. `visiting` holds the chain of // calc column ids being evaluated so cycles resolve to NaN instead of // recursing forever. [refs] read the current row, {refs} the whole filtered // column (both may point at other calc columns). const makeCalcResolvers = (item: any, visiting: Set) => { const resolveRef = (ref: string): number => { if (ref.startsWith(CALC_REF_PREFIX)) { const id = ref.slice(CALC_REF_PREFIX.length); if (visiting.has(id)) return NaN; const target = calcById().get(id); if (!target) return NaN; const next = new Set(visiting); next.add(id); return computeUserColumn(target, item, next); } return toCalcNumber(item?.[ref]); }; const resolveColumn = (ref: string): number[] => { const rows = aggregateRows(); if (ref.startsWith(CALC_REF_PREFIX)) { const id = ref.slice(CALC_REF_PREFIX.length); if (visiting.has(id)) return []; const target = calcById().get(id); if (!target) return []; const next = new Set(visiting); next.add(id); return rows.map(r => computeUserColumn(target, r, next)); } return rows.map(r => toCalcNumber(r?.[ref])); }; const rowIndex = () => { const i = aggregateRows().indexOf(item); return i < 0 ? NaN : i + 1; }; return { resolveRef, resolveColumn, rowIndex }; }; // Evaluate a calc spec (a saved column or an unsaved draft) for one row. const evalCalcSpec = (spec: { fn: UserCalculatedColumn["fn"]; operands: string[]; formula?: string }, compiled: FormulaNode | null, item: any, visiting: Set): number => { const { resolveRef, resolveColumn, rowIndex } = makeCalcResolvers(item, visiting); if (spec.fn === "custom") { if (!compiled) return NaN; const refs = calcNameToRef(); const lookup = (name: string) => refs.get(name.trim().toLowerCase()); return toScalar(compiled({ cell: (name) => { const ref = lookup(name); return ref === undefined ? NaN : resolveRef(ref); }, column: (name) => { const ref = lookup(name); return ref === undefined ? [] : resolveColumn(ref); }, row: rowIndex, })); } return applyCalcFunction(spec.fn, spec.operands.map(resolveRef)); }; const computeUserColumn = (uc: UserCalculatedColumn, item: any, visiting: Set): number => evalCalcSpec(uc, uc.fn === "custom" ? (compiledFormulas().get(uc.id) ?? null) : null, item, visiting); // Each user column becomes a regular AutoTableColumn carrying a `calculated` // spec, so the existing render/sort/export paths handle it. The compute // closure resolves calc-in-calc references and custom formulas; the synthetic // sortIdentifier ("_calc_") lets processDataLocally find and sort it. const userCalcToColumn = (uc: UserCalculatedColumn): AutoTableColumn => ({ displayName: uc.displayName, displayPosition: uc.displayPosition ?? COL_POS_RIGHT, sortable: isLocalFiltering(), sortIdentifier: CALC_REF_PREFIX + uc.id, csv: true, calculated: { fn: uc.fn, operands: [], dataType: uc.dataType, precision: uc.precision, compute: (item: any) => computeUserColumn(uc, item, new Set([uc.id])), }, }); const calcColumns = createMemo(() => userCalcColumns().map(userCalcToColumn)); // props.columns plus user calc columns — used for local sorting and export // so calculated columns participate in both. const effectiveColumns = createMemo(() => [...props.columns, ...calcColumns()]); // Columns the user can reference as operands: data columns with a field key, // plus other calculated columns (referenced as "_calc_"). const calcOperandOptions = createMemo(() => { const dataOpts = props.columns .filter(c => !!c.sortIdentifier) .map(c => ({ value: c.sortIdentifier!, label: c.displayName } as FormSelectOption)); const calcOpts = userCalcColumns() .map(c => ({ value: CALC_REF_PREFIX + c.id, label: c.displayName } as FormSelectOption)); return [...dataOpts, ...calcOpts]; }); const removeCalcColumn = (id: string) => setUserCalcColumns(prev => prev.filter(c => c.id !== id)); const saveCalcColumn = (col: UserCalculatedColumn) => { setUserCalcColumns(prev => { const idx = prev.findIndex(c => c.id === col.id); if (idx >= 0) { const next = [...prev]; next[idx] = col; return next; } return [...prev, col]; }); }; // -- User-created footer summary rows ------------------------------------ const loadUserSummaryRows = (): UserSummaryRow[] => { const key = opts().summaryRowsStorageKey; if (key) { try { const stored = localStorage.getItem(key); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed)) return parsed; } } catch { /* ignore */ } } return []; }; const [userSummaryRows, setUserSummaryRows] = createSignal(loadUserSummaryRows()); createEffect(() => { const key = opts().summaryRowsStorageKey; if (key && opts().summaryRows) { try { localStorage.setItem(key, JSON.stringify(userSummaryRows())); } catch { /* ignore */ } } }); const saveSummaryRow = (row: UserSummaryRow) => { setUserSummaryRows((prev: UserSummaryRow[]) => { const idx = prev.findIndex(r => r.id === row.id); if (idx >= 0) { const next = [...prev]; next[idx] = row; return next; } return [...prev, row]; }); }; const removeSummaryRow = (id: string) => setUserSummaryRows((prev: UserSummaryRow[]) => prev.filter(r => r.id !== id)); // Evaluate a summary formula once (no current row) over the filtered rows, then // format it. Reuses the column engine with item=null: [cell]/ROW() resolve to // NaN, {Col} aggregates resolve against aggregateRows(). const computeSummaryValue = (s: UserSummaryRow): string => { const fn = s.fn ?? "custom"; let n: number; if (fn === "custom") { let compiled: FormulaNode | null = null; try { compiled = compileFormula(s.formula ?? ""); } catch { return "—"; } n = evalCalcSpec({ fn: "custom", operands: [], formula: s.formula }, compiled, null, new Set()); } else { // Basic: aggregate the chosen column down the (filtered) rows. const key = s.operands && s.operands[0]; if (!key) return "—"; const { resolveColumn } = makeCalcResolvers(null, new Set()); n = applyCalcFunction(fn as CalculatedFunction, resolveColumn(key)); } return formatCalcResult(n, s.dataType, s.precision, undefined, undefined, "—"); }; // Computed summary lines for PDF export (label + formatted value). const pdfSummaries = () => opts().summaryRows ? userSummaryRows().map((s: UserSummaryRow) => ({ label: s.label, value: computeSummaryValue(s) })) : []; // Columns the calc editor highlights on hover (operand keys: a data column's // sortIdentifier or "_calc_"). A header is lit when its sortIdentifier is // in this set. const [highlightedCols, setHighlightedCols] = createSignal([] as string[]); const isColHighlighted = (col: AutoTableColumn) => !!col.sortIdentifier && highlightedCols().includes(col.sortIdentifier); // -- Column ordering (drag-to-reorder) ----------------------------------- // Columns are ordered by stable string keys so data and calculated columns // can interleave: data columns are "d", calc columns use their // sortIdentifier ("_calc_"). Calc columns join the orderable list only // when a cellRenderer is present (dragging needs per-cell control); with a // rowRenderer they stay appended after the data columns and aren't draggable. const DATA_COLUMN_KEY = (i: number) => "d" + i; const calcMerged = () => !!props.cellRenderer; const appendedCalcColumns = () => calcMerged() ? [] : calcColumns(); const allColumnKeys = createMemo(() => { const dataKeys = props.columns.map((_, i) => DATA_COLUMN_KEY(i)); if (!calcMerged()) return dataKeys; return [...dataKeys, ...userCalcColumns().map(uc => CALC_REF_PREFIX + uc.id)]; }); const columnByKey = createMemo(() => { const m = new Map(); props.columns.forEach((col, i) => m.set(DATA_COLUMN_KEY(i), { column: col, originalIndex: i })); if (calcMerged()) calcColumns().forEach((col) => m.set(col.sortIdentifier!, { column: col, originalIndex: -1 })); return m; }); const getInitialColumnOrder = (): string[] => { if (opts().columnOrderStorageKey) { try { const stored = localStorage.getItem(opts().columnOrderStorageKey!); if (stored) { const parsed = JSON.parse(stored); if (Array.isArray(parsed) && parsed.every((x: any) => typeof x === "string")) { return parsed; } } } catch { // Ignore localStorage errors (and the pre-key numeric format) } } return allColumnKeys(); }; const [columnOrder, setColumnOrder] = createSignal(getInitialColumnOrder()); // Reconcile the saved order with the current column set: keep known keys in // their order, append newly added columns, drop removed ones. Handles data // column changes and calc columns being added/removed/reordered. createEffect(() => { const keys = allColumnKeys(); const keySet = new Set(keys); const current = columnOrder(); const valid = current.filter(k => keySet.has(k)); const missing = keys.filter(k => !current.includes(k)); if (missing.length > 0 || valid.length !== current.length) { setColumnOrder([...valid, ...missing]); } }); createEffect(() => { if (opts().columnOrderStorageKey && opts().draggableColumns) { try { localStorage.setItem(opts().columnOrderStorageKey, JSON.stringify(columnOrder())); } catch { // Ignore localStorage errors } } }); let loadingTimerRef: ReturnType | null = null; let debounceTimerRef: ReturnType | null = null; let fetchIdRef = 0; const [filter, setFilter] = createSignal({ Pagination: { Disabled: false, CurrentPage: 1, NextPage: 1, PreviousPage: 1, TotalPages: 1, TotalItems: 0, MaxItemsPerPage: opts().hidePagination ? -1 : 25, ItemsThisPage: 0, ViewRangeLower: 0, ViewRangeUpper: 0, }, OrderBy: { Identifier: "", Descending: false, }, Search: [] as AutoTableSearchEntry[], ...props.initialFilter, } as AutoTableFilter); const [searchInputs, setSearchInputs] = createSignal(props.initialFilter?.Search || []); const [searchGeneration, setSearchGeneration] = createSignal(0); const bumpSearchGeneration = () => setSearchGeneration((g) => g + 1); const sourceData = createMemo(() => isRemote() ? remoteData() : (props.data || [])); // Rows that cross-row formula refs ({Column}) aggregate over: the filtered // set (search applied, sort/pagination ignored). Independent of localProcessed // so a calc column that aggregates can still be sorted without a cycle. For // server-side filtering only the loaded page is available. const aggregateRows = createMemo(() => isLocalFiltering() ? applySearchFilters(sourceData(), filter().Search) : remoteData()); const localProcessed = createMemo(() => { if (!isLocalFiltering()) return null; return processDataLocally(sourceData(), filter(), effectiveColumns()); }); const displayData = createMemo(() => isLocalFiltering() ? (localProcessed()?.data || []) : remoteData()); const allFilteredData = createMemo(() => isLocalFiltering() ? (localProcessed()?.allFilteredData || []) : remoteData()); const displayPagination = createMemo(() => isLocalFiltering() ? (localProcessed()?.pagination || filter().Pagination) : filter().Pagination); createEffect(() => { displayPagination().CurrentPage; filter().OrderBy.Identifier; filter().OrderBy.Descending; filter().Search; setExpandedRows(new Set()); }); // When a highlightMatch predicate is supplied and the matching row lives // on a different page than the one currently shown, jump to that page. // // The effect tracks only the predicate identity (caller is expected to // memoize it - a new function ref means "new highlight target") and // props.data (so we re-evaluate once the data finishes loading). All // other AutoTable state - filter, allFilteredData, pagination - is read // via untrack so user-driven page changes don't re-fire the jump and // pin them to the highlighted page. createEffect(() => { const match = props.highlightMatch; if (!match) return; // Touch props.data so the effect re-runs when the dataset loads. const data = props.data; if (!data) return; untrack(() => { const all = allFilteredData(); if (!all.length) return; const idx = all.findIndex((it: any) => match(it)); if (idx < 0) return; const f = filter(); const pageSize = f.Pagination.MaxItemsPerPage; if (!pageSize || pageSize < 0) return; const targetPage = Math.floor(idx / pageSize) + 1; if (targetPage !== f.Pagination.CurrentPage) { setFilter((prev: AutoTableFilter) => ({ ...prev, Pagination: { ...prev.Pagination, CurrentPage: targetPage }, })); } }); }); const fetchData = async (filterToUse: AutoTableFilter, isInitial = false) => { if (!props.url) return; const currentFetchId = ++fetchIdRef; setError(null); if (loadingTimerRef) { clearTimeout(loadingTimerRef); loadingTimerRef = null; } if (!isInitial) { loadingTimerRef = setTimeout(() => { setShowLoading(true); }, 500); } const startTime = isInitial ? Date.now() : 0; const minLoadingDuration = 300; try { const fetchUrl = !props.remoteFiltering ? props.url : props.url + "?" + buildQueryString(filterToUse); const response = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl); if (!response.ok) { throw new Error("HTTP " + response.status + ": " + response.statusText); } const result = await response.json(); if (currentFetchId !== fetchIdRef) return; if (isInitial) { const elapsed = Date.now() - startTime; if (elapsed < minLoadingDuration) { await new Promise(resolve => setTimeout(resolve, minLoadingDuration - elapsed)); } } if (!props.remoteFiltering) { const data = Array.isArray(result) ? result : (result.data || []); setRemoteData(data); } else { setRemoteData(result.data || []); setFilter((prev) => ({ ...prev, Pagination: result.filter.Pagination, OrderBy: result.filter.OrderBy, })); } } catch (err) { if (currentFetchId !== fetchIdRef) return; setError(err instanceof Error ? err.message : "Unknown error"); } finally { if (currentFetchId === fetchIdRef) { if (loadingTimerRef) { clearTimeout(loadingTimerRef); loadingTimerRef = null; } setInitialLoading(false); setShowLoading(false); } } }; createEffect(() => { void props.refreshSignal; if (isRemote()) { fetchData(untrack(() => filter()), true); } }); createEffect(() => { if (!props.onFilterChange) return; props.onFilterChange(filter()); }); const triggerFetch = (newFilter: AutoTableFilter) => { if (debounceTimerRef) { clearTimeout(debounceTimerRef); debounceTimerRef = null; } fetchData(newFilter); }; const handleSort = (identifier: string) => { if (isLocalFiltering()) { setFilter((prev) => ({ ...prev, OrderBy: { Identifier: identifier, Descending: prev.OrderBy.Identifier === identifier ? !prev.OrderBy.Descending : false, }, Pagination: { ...prev.Pagination, CurrentPage: 1 }, })); } else { setFilter((prev) => { const newFilter = { ...prev, OrderBy: { Identifier: identifier, Descending: prev.OrderBy.Identifier === identifier ? !prev.OrderBy.Descending : false, }, Pagination: { ...prev.Pagination, CurrentPage: 1 }, }; triggerFetch(newFilter); return newFilter; }); } }; const getSortIdentifier = (col: AutoTableColumn, colIndex: number): string | null => { if (col.sortIdentifier) return col.sortIdentifier; if (isLocalFiltering()) return "_col_" + colIndex; return null; }; const handlePageChange = (page: number) => { if (isLocalFiltering()) { setFilter((prev) => ({ ...prev, Pagination: { ...prev.Pagination, CurrentPage: page }, })); } else { setFilter((prev) => { const newFilter = { ...prev, Pagination: { ...prev.Pagination, CurrentPage: page }, }; triggerFetch(newFilter); return newFilter; }); } }; const handleItemsPerPageChange = (itemsPerPage: number) => { if (isLocalFiltering()) { setFilter((prev) => ({ ...prev, Pagination: { ...prev.Pagination, MaxItemsPerPage: itemsPerPage, CurrentPage: 1 }, })); } else { setFilter((prev) => { const newFilter = { ...prev, Pagination: { ...prev.Pagination, MaxItemsPerPage: itemsPerPage, CurrentPage: 1 }, }; triggerFetch(newFilter); return newFilter; }); } }; const handleSearch = (identifier: string, value: string, exact = false) => { const updateSearchArray = (searchArray: AutoTableSearchEntry[]): AutoTableSearchEntry[] => { const existingIndex = searchArray.findIndex(s => s.Identifier === identifier); let updated: AutoTableSearchEntry[]; if (existingIndex >= 0) { updated = [...searchArray]; updated[existingIndex] = { Identifier: identifier, Values: [value], Exact: exact }; } else { updated = [...searchArray, { Identifier: identifier, Values: [value], Exact: exact }]; } return updated.filter(s => s.Values.some(v => v)); }; if (isLocalFiltering()) { setFilter((prev) => ({ ...prev, Search: updateSearchArray(prev.Search), Pagination: { ...prev.Pagination, CurrentPage: 1 }, })); } else { const newSearchInputs = updateSearchArray(searchInputs()); setSearchInputs(newSearchInputs); if (debounceTimerRef) clearTimeout(debounceTimerRef); debounceTimerRef = setTimeout(() => { debounceTimerRef = null; setFilter((prev) => { const newFilter = { ...prev, Pagination: { ...prev.Pagination, CurrentPage: 1 }, Search: updateSearchArray(prev.Search), }; fetchData(newFilter); return newFilter; }); }, 150); } bumpSearchGeneration(); }; const readSearchValue = (identifier: string): string => { if (isLocalFiltering()) { return filter().Search.find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values[0] || ""; } return searchInputs().find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values[0] || ""; }; const getSearchValue = (identifier: string): string => { searchGeneration(); return readSearchValue(identifier); }; const setSearchValue = (identifier: string, value: string) => { handleSearch(identifier, value); }; const setSearchValueExact = (identifier: string, value: string) => { handleSearch(identifier, value, true); }; const getSearchValues = (identifier: string): string[] => { if (isLocalFiltering()) { return filter().Search.find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values || []; } return searchInputs().find((s: AutoTableSearchEntry) => s.Identifier === identifier)?.Values || []; }; const setSearchValues = (identifier: string, values: string[]) => { const updateSearchArray = (searchArray: AutoTableSearchEntry[]): AutoTableSearchEntry[] => { const existingIndex = searchArray.findIndex(s => s.Identifier === identifier); let updated: AutoTableSearchEntry[]; if (existingIndex >= 0) { updated = [...searchArray]; updated[existingIndex] = { Identifier: identifier, Values: values }; } else { updated = [...searchArray, { Identifier: identifier, Values: values }]; } return updated.filter(s => s.Values.length > 0); }; if (isLocalFiltering()) { setFilter((prev) => ({ ...prev, Search: updateSearchArray(prev.Search), Pagination: { ...prev.Pagination, CurrentPage: 1 }, })); } else { const newSearchInputs = updateSearchArray(searchInputs()); setSearchInputs(newSearchInputs); if (debounceTimerRef) clearTimeout(debounceTimerRef); debounceTimerRef = setTimeout(() => { debounceTimerRef = null; setFilter((prev) => { const newFilter = { ...prev, Pagination: { ...prev.Pagination, CurrentPage: 1 }, Search: updateSearchArray(prev.Search), }; fetchData(newFilter); return newFilter; }); }, 150); } bumpSearchGeneration(); }; const getMultiSearchValue = (fields: string[]): string => { return readSearchValue(createMultiSearchIdentifier(fields)); }; const setMultiSearchValue = (fields: string[], value: string) => { handleSearch(createMultiSearchIdentifier(fields), value); }; const getHeaderColorClass = () => HEADER_COLOR_CLS[opts().color]; const getHeaderSortHover = () => HEADER_SORT_HOVER_CLS[opts().color]; const getHeaderTextClass = () => HEADER_TEXT_CLS[opts().color]; const getHeaderDragClass = () => HEADER_DRAG_CLS[opts().color]; const getHeaderSortIconClass = () => HEADER_SORT_ICON_CLS[opts().color]; const getHeaderPaddingClass = () => HEADER_PADDING_CLS[opts().size]; const getBodyPaddingClass = () => BODY_PADDING_CLS[opts().size]; const getPaginationPaddingClass = () => PAGINATION_PADDING_CLS[opts().size]; const getRowHoverClass = () => opts().hover ? ROW_HOVER_CLS[opts().color] : ""; const handleDragStart = (e: DragEvent, orderIndex: number) => { if (!opts().draggableColumns) return; setDraggedColumn(orderIndex); e.dataTransfer!.effectAllowed = "move"; e.dataTransfer!.setData("text/plain", orderIndex.toString()); }; const handleDragOver = (e: DragEvent, orderIndex: number) => { if (!opts().draggableColumns || draggedColumn() === null) return; e.preventDefault(); e.dataTransfer!.dropEffect = "move"; if (dragOverColumn() !== orderIndex) { setDragOverColumn(orderIndex); } }; const handleDragLeave = () => { setDragOverColumn(null); }; const handleDrop = (e: DragEvent, targetOrderIndex: number) => { if (!opts().draggableColumns || draggedColumn() === null) return; e.preventDefault(); if (draggedColumn() !== targetOrderIndex) { setColumnOrder(prev => { const newOrder = [...prev]; const [removed] = newOrder.splice(draggedColumn() as number, 1); newOrder.splice(targetOrderIndex, 0, removed); return newOrder; }); } setDraggedColumn(null); setDragOverColumn(null); }; const handleDragEnd = () => { setDraggedColumn(null); setDragOverColumn(null); }; // -- Summary row reordering (drag-to-reorder, via the grip handle) -------- const handleSummaryDragStart = (e: DragEvent, index: number) => { setDraggedSummary(index); e.dataTransfer!.effectAllowed = "move"; e.dataTransfer!.setData("text/plain", index.toString()); }; const handleSummaryDragOver = (e: DragEvent, index: number) => { if (draggedSummary() === null) return; e.preventDefault(); e.dataTransfer!.dropEffect = "move"; if (dragOverSummary() !== index) setDragOverSummary(index); }; const handleSummaryDrop = (e: DragEvent, target: number) => { if (draggedSummary() === null) return; e.preventDefault(); const from = draggedSummary() as number; if (from !== target) { setUserSummaryRows((prev: UserSummaryRow[]) => { const next = [...prev]; const [moved] = next.splice(from, 1); next.splice(target, 0, moved); return next; }); } setDraggedSummary(null); setDragOverSummary(null); }; const handleSummaryDragEnd = () => { setDraggedSummary(null); setDragOverSummary(null); }; const resetColumnOrder = () => { setColumnOrder(allColumnKeys()); }; const resetColumnWidths = () => setColumnWidths({}); const resetCalcColumns = () => setUserCalcColumns([]); const resetSummaryRows = () => setUserSummaryRows([]); const resetAll = () => { resetCalcColumns(); resetSummaryRows(); resetColumnWidths(); resetColumnOrder(); }; // Show the reset menu only when there's something resettable enabled. const canReset = () => opts().resetButton && (opts().draggableColumns || opts().resizableColumns || opts().calculatedColumns || opts().summaryRows); const isLoading = () => initialLoading() || showLoading(); // The ordered, visible columns header & body iterate over. Built from the // stable-key order (or natural order when not draggable); calc columns are // included here only when merged (cellRenderer present). orderIndex is the // position within the full order, so drag handlers splice columnOrder by it. const displayColumnsWithIndices = createMemo(() => { const byKey = columnByKey(); const keys = opts().draggableColumns ? columnOrder() : allColumnKeys(); const mapped = keys .map((k, orderIdx) => { const entry = byKey.get(k); return entry ? { column: entry.column, originalIndex: entry.originalIndex, orderIndex: orderIdx, key: k } : null; }) .filter(Boolean) as { column: AutoTableColumn; originalIndex: number; orderIndex: number; key: string }[]; if (!opts().toggleColumns) return mapped; // Calc columns aren't part of the toggle set — always keep them. return mapped.filter(({ column, originalIndex }) => column.calculated || column.toggleable === false || visibleColumns().includes(originalIndex.toString())); }); // Columns shown: those in the ordered list plus calc columns appended when // not merged into the order (rowRenderer case). const renderedColumnCount = () => displayColumnsWithIndices().length + appendedCalcColumns().length; const totalColumnCount = () => renderedColumnCount() + (opts().accordion ? 1 : 0); // When every visible column has a pinned width, give the table an explicit // px width equal to their sum. table-fixed then honors each width exactly, so // resizing one column only moves that boundary (Excel-style) instead of // reflowing the others; the table grows/scrolls as widths change. Returns // null (→ min-w-full) until all columns are pinned, e.g. before first resize. // The accordion toggle column is a fixed 40px (w-10). const ACCORDION_TOGGLE_WIDTH = 40; const pinnedTableWidth = createMemo(() => { if (!opts().resizableColumns) return null; const widths = columnWidths(); if (Object.keys(widths).length === 0) return null; let sum = opts().accordion ? ACCORDION_TOGGLE_WIDTH : 0; for (const c of displayColumnsWithIndices()) { const w = widths[c.key]; if (w == null) return null; sum += w; } for (const c of appendedCalcColumns()) { const w = c.sortIdentifier ? widths[c.sortIdentifier] : undefined; if (w == null) return null; sum += w; } return sum; }); const hasCalculatedColumns = createMemo(() => props.columns.some(c => !!c.calculated) || userCalcColumns().length > 0); // Calculated columns are rendered by AutoTable itself (the cellRenderer never // sees them). Numeric results read best right-aligned, so default to that. const renderCalculatedCell = (col: AutoTableColumn, item: any) => { const pos = col.displayPosition ?? COL_POS_RIGHT; return {formatCalculatedValue(item, col.calculated!)}; }; const columnToggleOptions = createMemo(() => { return props.columns .map((col, i) => ({ value: i.toString(), label: col.displayName, toggleable: col.toggleable })) .filter(opt => opt.toggleable !== false); }); const handleExportCSV = async (): Promise => { if (isExporting()) return; setIsExporting(true); try { const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()]; if (isLocalFiltering()) { const filterWithoutPagination: AutoTableFilter = { ...filter(), Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 }, }; const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns); downloadCSV(processed.data, exportColumns, effectiveExportFilename()); } else if (props.url && props.remoteFiltering) { const exportFilter: AutoTableFilter = { ...filter(), Search: searchInputs(), Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 }, }; const fetchUrl = props.url + "?" + buildQueryString(exportFilter); const response = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl); if (!response.ok) { throw new Error("HTTP " + response.status + ": " + response.statusText); } const result = await response.json(); downloadCSV(result.data || [], exportColumns, effectiveExportFilename()); } } catch (err) { console.error("Export failed:", err); } finally { setIsExporting(false); } }; const handleExportPDF = async () => { if (isExporting()) return; setIsExporting(true); try { const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()]; if (isLocalFiltering()) { const filterWithoutPagination = { ...filter(), Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 }, }; const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns); await downloadPDF(processed.data, exportColumns, effectiveExportFilename(), effectivePdfHeader(), null, pdfSummaries()); } else if (props.url && props.remoteFiltering) { const exportFilter = { ...filter(), Search: searchInputs(), Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 }, }; const fetchUrl = props.url + "?" + buildQueryString(exportFilter); const resp = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl); if (!resp.ok) { throw new Error("HTTP " + resp.status + ": " + resp.statusText); } const result = await resp.json(); await downloadPDF(result.data || [], exportColumns, effectiveExportFilename(), effectivePdfHeader(), result, pdfSummaries()); } } catch (err) { console.error("Export failed:", err); } finally { setIsExporting(false); } }; const handlePrintPDF = async () => { if (isExporting()) return; setIsExporting(true); try { const exportColumns = [...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()]; let pdfBytes = null; if (isLocalFiltering()) { const filterWithoutPagination = { ...filter(), Pagination: { ...filter().Pagination, MaxItemsPerPage: -1 }, }; const processed = processDataLocally(sourceData(), filterWithoutPagination, props.columns); pdfBytes = await buildTablePDF(processed.data, exportColumns, effectivePdfHeader(), null, pdfSummaries()); } else if (props.url && props.remoteFiltering) { const exportFilter = { ...filter(), Search: searchInputs(), Pagination: { ...filter().Pagination, MaxItemsPerPage: -1, CurrentPage: 1 }, }; const fetchUrl = props.url + "?" + buildQueryString(exportFilter); const resp = props.requireAuth ? await authFetch(fetchUrl) : await fetch(fetchUrl); if (!resp.ok) { throw new Error("HTTP " + resp.status + ": " + resp.statusText); } const result = await resp.json(); pdfBytes = await buildTablePDF(result.data || [], exportColumns, effectivePdfHeader(), result, pdfSummaries()); } if (pdfBytes) { const blob = new Blob([pdfBytes as BlobPart], { type: "application/pdf" }); const url = URL.createObjectURL(blob); const iframe = document.createElement("iframe"); iframe.style.cssText = "position:fixed;right:0;bottom:0;width:1px;height:1px;border:none;opacity:0;pointer-events:none"; iframe.src = url; iframe.onload = () => { iframe.contentWindow!.focus(); iframe.contentWindow!.print(); }; document.body.appendChild(iframe); } } catch (err) { console.error("PDF print failed:", err); } finally { setIsExporting(false); } }; const showExportMenu = () => opts().exportCSV || !!props.exportMenuItems; const showToolbar = () => opts().toggleColumns || showExportMenu() || opts().calculatedColumns || opts().summaryRows || !!props.toolbarActions; // Actions are only "on the filter row" when a filter row exists. Without // searchFields there's nothing to push them right, so fall through to // TOOLBAR_ACTIONS (lg:ml-auto) instead of the inline variant and keep the // toolbar (e.g. a lone Export button) right-aligned. const actionsOnFilterRow = () => !!props.searchFields && (opts().inlineToolbar || showToolbar()); const toolbarActionsCls = () => actionsOnFilterRow() ? TOOLBAR_ACTIONS_INLINE : TOOLBAR_ACTIONS; const desktopRowCls = () => TOOLBAR_DESKTOP_ROW; const panelActionsCls = () => actionsOnFilterRow() ? PANEL_ACTIONS_INLINE : PANEL_ACTIONS_DEFAULT; const resolveExportMenuItems = () => { const items = props.exportMenuItems; if (items == null) return null; return typeof items === "function" ? items({ allFilteredData }) : items; }; const toolbarButtons = () => showToolbar() ? (
{props.toolbarActions && props.toolbarActions({ allFilteredData })} Add Calculation setHighlightedCols(keys ?? [])} /> Columns } options={columnToggleOptions()} value={visibleColumns()} onchange={setVisibleColumns} align="right" minWidth={180} showSelectAll={true} searchable={true} small={true} /> Export Options
Export settings
File name setExportFilenameInput(e.currentTarget.value)} />
PDF title setPdfTitle(e.currentTarget.value)} />
PDF subtitle setPdfSubtitle(e.currentTarget.value)} />
Orientation setPdfOrientation(e.currentTarget.value)}>
Export Spreadsheet handleExportCSV()} disabled={isExporting()} > {isExporting() ? "Exporting..." : "Export CSV"} {resolveExportMenuItems()} PDF handleExportPDF()} disabled={isExporting()} > {isExporting() ? "Exporting..." : "Export PDF"} handlePrintPDF()} disabled={isExporting()} > {isExporting() ? "Loading..." : "Print PDF"}
) : null; // Main render const searchFieldsCtx: SearchFieldsContext = { getSearchValue, setSearchValue, setSearchValueExact, getSearchValues, setSearchValues, getMultiSearchValue, setMultiSearchValue, resetColumnOrder, }; const [filtersOpen, setFiltersOpen] = createSignal(false); const activeFilterCount = createMemo(() => { const searches = isLocalFiltering() ? filter().Search : searchInputs(); return searches.filter((s: AutoTableSearchEntry) => s.Values.some(v => v)).length; }); const toggleFilters = () => setFiltersOpen(v => !v); const inlineFiltersBodyCls = () => { let cls = actionsOnFilterRow() ? FILTERS_BODY_INLINE : FILTERS_BODY_BASE + " " + FILTERS_BODY_DESKTOP_FLEX; cls += filtersOpen() ? " max-lg:flex" : " max-lg:hidden"; return cls; }; const asideFiltersBodyCls = () => FILTERS_BODY_ASIDE + (filtersOpen() ? " max-lg:flex" : " max-lg:hidden") + " lg:flex"; const filtersToggleBtn = ; const toolbarPanelActions = () => showToolbar() ? (
{toolbarButtons()}
) : null; return
{filtersToggleBtn}
{untrack(() => props.searchFields!(searchFieldsCtx))} {toolbarPanelActions()}
{props.aboveTable}
{({ column: col, originalIndex: originalIdx, orderIndex: orderIdx, key: colKey }, displayIdx) => { const isDragging = () => draggedColumn() === orderIdx; const isDragOver = () => dragOverColumn() === orderIdx && draggedColumn() !== orderIdx; const pos = col.displayPosition ?? COL_POS_LEFT; const posCls = POS_CLS[pos]; const headerInnerPosCls = HEADER_INNER_POS[pos]; return ; }} {(uc: UserCalculatedColumn, calcIdx) => { const sortId = "_calc_" + uc.id; const sortable = () => isLocalFiltering(); const pos = uc.displayPosition ?? COL_POS_RIGHT; const isLastColumn = () => calcIdx() === userCalcColumns().length - 1; return ; }} {(_, rowIdx) => column), ...appendedCalcColumns()]}> {() => } } 0 && displayData().length === 0}> 0 && displayData().length > 0}> {(item, rowIdx) => { const position = () => (displayPagination().CurrentPage - 1) * displayPagination().MaxItemsPerPage + rowIdx() + 1; const isLastRow = () => rowIdx() === displayData().length - 1; const rowKey = () => item.id ?? rowIdx(); const isExpanded = () => opts().accordion && expandedRows().has(rowKey()); const isHighlighted = () => !!(props.highlightMatch && props.highlightMatch(item)); return [ { let c = ""; if (isHighlighted()) { // Highlight overrides alternate + hover with !important. c += "bg-amber-100! [&>td:first-child]:shadow-[inset_3px_0_0_var(--color-amber-500)] "; } else { if (opts().alternate && rowIdx() % 2 === 1) c += "bg-neutral-100 "; c += getRowHoverClass() + " "; } if (isExpanded() || (opts().borderX && !isLastRow())) c += "border-b border-neutral-300 "; if (opts().accordion) c += "cursor-pointer select-none "; return c.trim(); })()} onclick={opts().accordion ? () => toggleAccordion(rowKey()) : undefined} > {({ column: col, originalIndex: originalIdx }) => { if (col.calculated) return renderCalculatedCell(col, item); return props.cellRenderer!(item, col, originalIdx, position(), rowIdx()); }} {(col: AutoTableColumn) => renderCalculatedCell(col, item)} , ]; }} 0}> {(s: UserSummaryRow, sIdx: () => number) => { const value = () => computeSummaryValue(s); // Always right-aligned: the value sits in the last column and the label // in the cell immediately to its left (or they share the one cell in a // single-column table). All other cells stay empty. const cols = () => [...displayColumnsWithIndices().map((e: { column: AutoTableColumn }) => e.column), ...appendedCalcColumns()]; const valueIdx = () => cols().length - 1; const labelIdx = () => { const v = valueIdx(); return v > 0 ? v - 1 : v; }; return handleSummaryDragOver(e, sIdx())} onDragLeave={() => setDragOverSummary(null)} onDrop={(e: DragEvent) => handleSummaryDrop(e, sIdx())}> {(_col: AutoTableColumn, idx: () => number) => { const isLbl = () => idx() === labelIdx(); const isVal = () => idx() === valueIdx(); const isFirst = () => idx() === 0; return ; }} ; }}
  handleDragStart(e, orderIdx)} onDragOver={(e) => handleDragOver(e, orderIdx)} onDragLeave={handleDragLeave} onDrop={(e) => handleDrop(e, orderIdx)} onDragEnd={handleDragEnd} style={columnWidthStyle(colKey)} class={getHeaderPaddingClass() + " " + getHeaderColorClass() + (opts().resizableColumns ? " relative" : "") + (posCls ? " " + posCls : "") + (opts().headerBorderY && displayIdx() > 0 ? " border-l border-l-neutral-300" : "") + (col.sortable ? " cursor-pointer" : "") + (col.sortable && !resizingColumn() ? " " + getHeaderSortHover() : "") + (opts().draggableColumns ? " select-none cursor-grab active:cursor-grabbing" : "") + (isDragOver() ? "outline-2 outline-sky-500 -outline-offset-2" : "") + (isColHighlighted(col) ? "outline-2 -outline-offset-2 outline-amber-400" : "") + (col.headerClasses ? " " + col.headerClasses : "")} onclick={() => { if (col.sortable) { const sortId = getSortIdentifier(col, originalIdx); if (sortId) handleSort(sortId); } }} >
startColumnResize(e, colKey)} onclick={(e: MouseEvent) => e.stopPropagation()}>
e.stopPropagation()}> calcById().get((col.sortIdentifier || "").slice(CALC_REF_PREFIX.length)) ?? null} operandOptions={calcOperandOptions} onSave={saveCalcColumn} onRemove={removeCalcColumn} onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])} />
{col.displayName}
}>
{ if (sortable()) handleSort(sortId); }}>
startColumnResize(e, sortId)} onclick={(e: MouseEvent) => e.stopPropagation()}>
e.stopPropagation()}> uc} operandOptions={calcOperandOptions} onSave={saveCalcColumn} onRemove={removeCalcColumn} onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])} />
{uc.displayName}
}>
Error: {error()}
No columns selected.
{(typeof props.emptyMessage === "function" ? props.emptyMessage() : props.emptyMessage) || "No entries found."}
{props.accordionRenderer!(item, item[accordionKey()])}
1}>
{s.label} s} operandOptions={calcOperandOptions} onSave={saveSummaryRow} onRemove={removeSummaryRow} onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])} /> {value()}
Reset resetColumnOrder()}>Reset column order resetColumnWidths()}>Reset column widths resetCalcColumns()}>Reset calculations resetSummaryRows()}>Reset summaries resetAll()}>Reset all
{displayPagination().ViewRangeLower}-{displayPagination().ViewRangeUpper} of {displayPagination().TotalItems}
Items per page:
handleItemsPerPageChange(parseInt(e.currentTarget.value))} > handlePageChange(1)} disabled={displayPagination().CurrentPage <= 1} > handlePageChange(displayPagination().CurrentPage - 1)} disabled={displayPagination().CurrentPage <= 1} >
Page {displayPagination().CurrentPage} of {displayPagination().TotalPages}
handlePageChange(displayPagination().CurrentPage + 1)} disabled={displayPagination().CurrentPage >= displayPagination().TotalPages} > handlePageChange(displayPagination().TotalPages)} disabled={displayPagination().CurrentPage >= displayPagination().TotalPages} >
{props.belowTable}
; } function PaginationButton(props: JSX.ButtonHTMLAttributes) { return ; } interface TdProps { class?: string; style?: string; children: any; } export function TdLeft(props: TdProps) { return {props.children}; } export function TdRight(props: TdProps) { return {props.children}; } export function TdCenter(props: TdProps) { return {props.children}; } export function TdUniformLeft(props: TdProps) { return {props.children}; } export function TdUniformRight(props: TdProps) { return {props.children}; } export function TdUniformCenter(props: TdProps) { return {props.children}; } export default AutoTable;