Files
kjol/go/jsruntime/uikit/AutoTable.tsx

4215 lines
208 KiB
TypeScript

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_<id>" 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<string, number> = {
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<void>;
}
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 <tfoot> 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<AutoTableFilter>;
accordionKey?: string;
searchFields?: (ctx: SearchFieldsContext) => JSXElement;
aboveTable?: JSXElement;
footer?: JSXElement;
belowTable?: JSXElement;
emptyMessage?: string | (() => string);
rowRenderer?: (item: any, position: number, rowIdx: number) => JSXElement;
cellRenderer?: (item: any, col: AutoTableColumn, colIdx: number, position: number, rowIdx: number) => JSXElement;
accordionRenderer?: (item: any, accordionData: any) => JSXElement;
toolbarActions?: (ctx: ToolbarActionsContext) => JSXElement;
/** Extra items rendered inside the Export dropdown (e.g. QuickBooks CSV). */
exportMenuItems?: (ctx: ToolbarActionsContext) => JSXElement;
// 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 <div class={fieldCls()}>
<label>{props.label}</label>
<FormInput
small={true}
placeholder={props.placeholder}
value={props.value}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => props.onchange(e.currentTarget.value)}
/>
</div>;
}
export interface AutoTableDateSearchProps {
label: string;
identifier?: string;
getSearchValue?: (identifier: string) => string;
value?: MaybeAccessor<string>;
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 (
<div class={fieldCls()}>
<label>{props.label}</label>
<DatePicker
small={true}
clearable={true}
value={liveValue()}
onchange={props.onchange}
placeholder={props.placeholder}
/>
</div>
);
}
export function AutoTableMultiSearch(props: AutoTableMultiSearchProps) {
return <div class={AUTOTABLE_SEARCH_FIELD + " " + (props.class || "")}>
<Show when={props.label}>
<label>{props.label}</label>
</Show>
<FormInput
small={true}
placeholder={props.placeholder}
value={props.value}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => props.onchange(e.currentTarget.value)}
/>
</div>;
}
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<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",
};
// Sortable hover override per color (applied when `sortable`).
const HEADER_SORT_HOVER_CLS: Record<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 weight/case styling per color.
export const HEADER_TEXT_CLS: Record<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",
};
// Drag-handle color (muted variant of header color).
const HEADER_DRAG_CLS: Record<AutoTableHeaderColor, string> = {
[AUTOTABLE_HEADER_COLOR_DEFAULT]: "text-ink-muted",
[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<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",
};
// Th cell padding per table size.
export const HEADER_PADDING_CLS: Record<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 cell padding per table size — applied via [&_td]: on the tbody.
export const BODY_PADDING_CLS: Record<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",
};
// Pagination padding per table size.
const PAGINATION_PADDING_CLS: Record<AutoTableSize, string> = {
[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<AutoTableHeaderColor, string> = {
[AUTOTABLE_HEADER_COLOR_DEFAULT]: "hover:bg-surface-strong",
[AUTOTABLE_HEADER_COLOR_BLUE]: "hover:bg-sky-100 dark:bg-sky-950/50",
[AUTOTABLE_HEADER_COLOR_GREEN]: "hover:bg-green-100 dark:bg-green-950/50",
[AUTOTABLE_HEADER_COLOR_GRAY]: "hover:bg-surface-strong",
[AUTOTABLE_HEADER_COLOR_DARK_BLUE]: "hover:bg-sky-100 dark:bg-sky-950/50",
};
// Alignment per ColumnPosition.
export const POS_CLS: Record<ColumnPosition, string> = {
[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<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 --
export const TBL_CONTAINER = "relative flex flex-col w-full h-full bg-surface 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-ink-faint";
const ACCORDION_TOGGLE_TD = "w-10 text-center text-ink-faint";
const ACCORDION_ICON = "inline-block transition-transform duration-200";
const SKELETON = "h-4 bg-surface-strong rounded-default animate-pulse";
const ERROR_CELL = "text-center text-red-600 dark:text-red-400";
const EMPTY_CELL = "text-center text-ink-muted";
const PAGINATION_BASE = "flex justify-between items-center border-t border-line-strong";
const PAGINATION_INFO = "hidden sm:flex items-center text-sm text-ink-muted";
const PAGINATION_CONTROLS = "flex items-center";
const PAGINATION_LABEL = "hidden sm:block text-sm text-ink-muted mr-2";
const PAGINATION_PAGE = "text-sm text-ink-muted px-3";
const PAGINATION_BTN = "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";
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-line";
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
// <button> (a PopoverTrigger can't wrap a ButtonUI without nesting buttons).
const CALC_ADD_TRIGGER_CLS = "inline-flex items-center justify-center gap-2 cursor-pointer text-sm font-normal rounded-default transition shadow-xs bg-surface-muted text-ink border border-line-strong hover:bg-surface-raised py-1 px-3";
// Drag affordance on the right edge of a resizable column header.
const RESIZE_HANDLE_CLS = "absolute top-0 right-0 h-full w-[6px] cursor-col-resize select-none z-10 hover:bg-neutral-400/40 active:bg-neutral-500/50";
// Shared metrics so the formula textarea and its highlight overlay align exactly.
// Sized to match a small form input (h-[30px], p-1, text-sm). Kept to a single
// horizontally-scrolling line (whitespace-pre + wrap="off") so it's never taller
// than any other input; the overlay scroll is kept in sync (see syncScroll).
const FORMULA_EDIT_BASE = "block w-full h-[30px] font-mono text-sm p-1 rounded-default box-border whitespace-pre";
const FORMULA_OVERLAY_CLS = FORMULA_EDIT_BASE + " absolute inset-0 overflow-hidden pointer-events-none border border-transparent text-ink";
const FORMULA_TEXTAREA_CLS = FORMULA_EDIT_BASE + " relative bg-transparent text-transparent caret-ink resize-none overflow-x-auto overflow-y-hidden shadow-xs border border-line-strong focus:border-sky-500 outline-hidden";
const FORMULA_PLACEHOLDER = `<span class="text-ink-faint">e.g. [Revenue] / SUM({Revenue}) * 100</span>`;
// Mobile: filters collapse behind a toggle; lg+ always shows the filter row inline.
const FILTERS_TOGGLE = "lg:hidden inline-flex w-full items-center justify-between gap-2 py-1 px-3 text-sm font-normal text-ink bg-surface-muted border border-line-strong rounded-default shadow-xs cursor-pointer hover:bg-surface-raised";
const FILTERS_BADGE = "inline-flex items-center justify-center min-w-5 h-5 px-1.5 text-xs font-semibold text-white bg-sky-700 rounded-full";
const FILTERS_BODY_BASE = "grow flex flex-col gap-3 min-w-0 max-w-full items-stretch overflow-visible lg:items-end max-lg:[&>*]:w-full max-lg:[&>*]:min-w-0 max-lg:[&_.search-fields]:w-full max-lg:[&_.search-fields]:flex-col [&>.quick-date-tags]:w-full";
const FILTERS_BODY_INLINE = FILTERS_BODY_BASE
+ " lg:flex lg:flex-row lg:flex-wrap lg:items-end lg:gap-x-3.5 lg:gap-y-2.5"
+ " lg:[&>.quick-date-tags]:w-full lg:[&>.quick-date-tags]:basis-full"
+ " lg:[&>.sales-report-toolbar__presets]:w-full lg:[&>.sales-report-toolbar__presets]:basis-full"
+ " lg:[&>.search-fields]:flex-1 lg:[&>.search-fields]:min-w-0 lg:[&>.sales-report-toolbar__filters]:flex-1 lg:[&>.sales-report-toolbar__filters]:min-w-0";
const FILTERS_BODY_DESKTOP_FLEX = "lg:flex-row lg:flex-wrap lg:items-end lg:gap-4 lg:[&>*:not(.quick-date-tags)]:w-auto lg:[&_.search-fields]:flex-1 lg:[&_.search-fields]:min-w-0 lg:[&_.search-fields]:w-auto";
const FILTERS_BODY_ASIDE = "flex flex-col gap-3 max-lg:[&>*]:w-full max-lg:[&>*]:min-w-0";
/** Default AutoTable filter row — stacked on mobile, wrapping row on desktop. */
export const SEARCH_FIELDS = "search-fields flex flex-col items-stretch gap-3 w-full overflow-visible lg:flex-row lg:flex-wrap lg:items-end lg:gap-x-3 lg:gap-y-2.5 lg:w-auto lg:min-w-0 [&>*]:min-w-0 [&>*]:w-full lg:[&>*]:w-auto lg:[&>*:not(.search-fields-spacer):not(.sales-list-filter-date):not(.sales-list-filter-ref):not(.sales-report-filter-date):not(.sales-report-filter-ref)]:flex-[1_1_9rem] lg:[&>*:not(.search-fields-spacer):not(.sales-list-filter-date):not(.sales-list-filter-ref):not(.sales-report-filter-date):not(.sales-report-filter-ref)]:min-w-[8rem] lg:[&>*:not(.search-fields-spacer):not(.sales-list-filter-date):not(.sales-list-filter-ref):not(.sales-report-filter-date):not(.sales-report-filter-ref)]:max-w-[14rem] [&_.relative]:w-full [&_.relative]:min-w-0 [&_.relative]:flex-none [&_.search-fields-spacer]:hidden [&_.search-fields-spacer]:basis-full lg:[&_.search-fields-spacer]:block lg:[&_.search-fields-spacer]:flex-1 lg:[&_.search-fields-spacer]:min-w-4 lg:[&_.search-fields-spacer]:basis-auto lg:[&_.search-fields-spacer]:max-w-none lg:[&_.search-fields-spacer]:min-w-[1rem]";
/** Pushes toolbar actions to the end when embedded inside a filter row. */
export const SEARCH_FIELDS_SPACER = "search-fields-spacer hidden lg:block flex-1 min-w-4 basis-full lg:basis-auto lg:min-w-[1rem]";
/** Combobox / custom filter control wrapper — pair with FormCombobox fieldWidth="grow". */
export const AUTOTABLE_FILTER_FIELD = "flex flex-col gap-1 min-w-0 w-full overflow-visible [&>label]:text-xs [&>label]:font-medium [&>label]:text-ink [&_.relative]:w-full [&_.relative]:min-w-0 [&_.relative]:flex-none";
/** Sales report toolbar wrappers (presets row 1, filters + export row 2). */
export const SALES_REPORT_TOOLBAR = "sales-report-toolbar flex flex-col gap-2.5 w-full min-w-0 lg:contents";
export const SALES_REPORT_PRESETS = "sales-report-toolbar__presets min-w-0 w-full [&_.quick-date-tags]:gap-1.5";
export const SALES_REPORT_FILTERS = "search-fields sales-report-toolbar__filters flex flex-col items-stretch gap-3 w-full overflow-visible lg:flex-row lg:flex-wrap lg:items-end lg:gap-x-3.5 lg:gap-y-2.5 lg:w-auto lg:min-w-0 min-w-0 pt-0.5 border-t border-line [&>*]:min-w-0 [&>*]:max-lg:w-full lg:[&>*]:w-auto lg:[&>*:not(.sales-report-filter-date):not(.sales-report-filter-ref)]:flex-[1_1_8rem] lg:[&>*:not(.sales-report-filter-date):not(.sales-report-filter-ref)]:max-w-[12rem] [&_.relative]:w-full [&_.relative]:min-w-0 [&_.relative]:flex-none";
export const AUTOTABLE_SEARCH_FIELD = "flex flex-col gap-1 min-w-0 w-full [&>label]:text-xs [&>label]:font-medium [&>label]:text-ink [&_.relative]:w-full [&_.relative]:min-w-0";
export const AUTOTABLE_SEARCH_FIELD_WIDE = AUTOTABLE_SEARCH_FIELD + " lg:flex-[2_1_12rem] lg:max-w-[20rem]";
export const AUTOTABLE_SEARCH_FIELD_NARROW = AUTOTABLE_SEARCH_FIELD + " lg:flex-[0_1_auto] lg:max-w-[11rem] lg:min-w-[7rem]";
export const AUTOTABLE_DATE_SEARCH_FIELD = AUTOTABLE_SEARCH_FIELD + " lg:flex-[1_1_10rem] lg:max-w-none";
export const SALES_REPORT_FILTER_FIELD = AUTOTABLE_FILTER_FIELD + " sales-report-filter-field lg:flex-[1_1_8rem] lg:max-w-[12rem]";
export const SALES_REPORT_FILTER_FIELD_WIDE = SALES_REPORT_FILTER_FIELD + " sales-report-filter-field--wide lg:flex-[2_1_10rem] lg:max-w-[16rem]";
export const SALES_REPORT_FILTER_DATE = AUTOTABLE_SEARCH_FIELD + " sales-report-filter-date lg:flex-[0_1_auto] lg:w-[9rem] lg:min-w-[8.5rem] lg:max-w-[9.5rem]";
export const SALES_REPORT_FILTER_SEARCH_WIDE = AUTOTABLE_SEARCH_FIELD + " sales-report-filter-search--wide lg:flex-[2_1_10rem] lg:max-w-[12rem] lg:min-w-[8rem]";
export const SALES_REPORT_FILTER_REF = AUTOTABLE_SEARCH_FIELD + " sales-report-filter-ref lg:flex-[0_1_auto] lg:w-[4.5rem] lg:min-w-[4rem] lg:max-w-[5rem]";
/** Sales list (/app/manager/sales) — compact date + car # filters. */
export const SALES_LIST_FILTER_DATE = AUTOTABLE_SEARCH_FIELD + " sales-list-filter-date lg:flex-[0_1_auto] lg:w-[8.25rem] lg:min-w-[8rem] lg:max-w-[8.75rem]";
export const SALES_LIST_FILTER_REF = AUTOTABLE_SEARCH_FIELD + " sales-list-filter-ref lg:flex-[0_1_auto] lg:w-[3.75rem] lg:min-w-[3.5rem] lg:max-w-[4.25rem]";
// Side filter panel (opts.searchAside): a bordered card to the left of the table.
const SEARCH_CARD = "w-64 shrink-0 bg-surface border border-line-strong rounded-default shadow-sm p-4 max-lg:w-full";
/** Groups AutoTable filter controls — stacked on mobile, horizontal on desktop. */
export function AutoTableFilterFields(props: { children?: JSXElement; class?: string }) {
const cls = () => props.class || SEARCH_FIELDS;
return <div class={cls()}>{props.children}</div>;
}
const PAGE_NUM_KEY = "page_num";
const ORDER_BY_KEY = "order_by";
const ITEMS_PER_PAGE_KEY = "items_per_page";
const SEARCH_KEY_PREFIX = "search_";
export function buildQueryString(filter: AutoTableFilter): string {
const params = new URLSearchParams();
params.set(PAGE_NUM_KEY, filter.Pagination.CurrentPage.toString());
params.set(ITEMS_PER_PAGE_KEY, filter.Pagination.MaxItemsPerPage.toString());
if (filter.OrderBy.Identifier) {
params.set(ORDER_BY_KEY, filter.OrderBy.Identifier);
params.set("order_desc", filter.OrderBy.Descending.toString());
}
for (const search of filter.Search) {
for (const value of search.Values) {
if (value) {
params.append(SEARCH_KEY_PREFIX + search.Identifier, value);
}
}
}
return params.toString();
}
// Parse a row value into a number, tolerating formatted strings like "$1,234.56"
// or "12%". Returns NaN when there's nothing numeric to read.
function toCalcNumber(value: any): number {
if (typeof value === "number") return value;
if (value === null || value === undefined) return NaN;
const cleaned = String(value).replace(/[$,%\s]/g, "");
if (cleaned === "") return NaN;
return parseFloat(cleaned);
}
// Apply a predefined function to a list of operand numbers. Aggregates
// (sum/average/median/mode/min/max/count) ignore NaN operands; arithmetic
// (subtract/multiply/divide) yields NaN if any operand is NaN.
function applyCalcFunction(fn: CalculatedFunction, operands: number[]): number {
if (fn === "count") return operands.filter(n => !Number.isNaN(n)).length;
if (operands.length === 0) return NaN;
switch (fn) {
case "sum":
case "average":
case "min":
case "max": {
const nums = operands.filter(n => !Number.isNaN(n));
if (nums.length === 0) return NaN;
if (fn === "sum") return nums.reduce((a, b) => a + b, 0);
if (fn === "average") return nums.reduce((a, b) => a + b, 0) / nums.length;
if (fn === "min") return Math.min(...nums);
return Math.max(...nums);
}
case "median": {
const nums = operands.filter(n => !Number.isNaN(n)).sort((a, b) => a - b);
if (nums.length === 0) return NaN;
const mid = Math.floor(nums.length / 2);
return nums.length % 2 ? nums[mid] : (nums[mid - 1] + nums[mid]) / 2;
}
case "mode": {
const nums = operands.filter(n => !Number.isNaN(n));
if (nums.length === 0) return NaN;
const counts = new Map<number, number>();
let best = NaN, bestCount = 0;
for (const n of nums) {
const c = (counts.get(n) ?? 0) + 1;
counts.set(n, c);
if (c > bestCount) { bestCount = c; best = n; }
}
return bestCount > 1 ? best : NaN; // no repeated value -> no mode
}
case "subtract":
if (operands.some(n => Number.isNaN(n))) return NaN;
return operands.reduce((a, b) => a - b);
case "multiply":
if (operands.some(n => Number.isNaN(n))) return NaN;
return operands.reduce((a, b) => a * b, 1);
case "divide":
if (operands.some(n => Number.isNaN(n))) return NaN;
return operands.reduce((a, b) => b === 0 ? NaN : a / b);
default:
return NaN;
}
}
// -- Excel-style formula support (the "Custom" calculated-column function) ----
// A small, dependency-free expression evaluator. Supports + - * / ^, comparisons
// (= <> < > <= >=), modulo (%), unary minus, parentheses, numeric literals,
// references, and a fixed set of functions (SUM, AVERAGE/AVG, MIN, MAX, COUNT, IF,
// ABS, ROUND, FLOOR, CEILING/CEIL, SQRT, POWER, MOD, AND, OR, NOT, ROW).
//
// Reference kinds let formulas span both columns and rows:
// [Name] -> the current row's value for that column (a scalar)
// {Name} -> that column's values across all (filtered) rows (an array)
// {Name:n} -> the nth (1-based) row of that column (a scalar)
// {Name:a:b} -> rows a..b inclusive (an array; Excel-style ":" range)
// e.g. [Revenue] / SUM({Revenue}) * 100 (% of column total)
// [Score] - AVERAGE({Score}) (deviation from the column mean)
// SUM({Revenue:1:ROW()}) (running total to the current row)
// AVERAGE({Sales:ROW()-2:ROW()}) (trailing 3-row moving average)
// Indices/bounds can be any expression; ROW() is the current row's number, so
// {Revenue:ROW()-1} is the previous row's revenue and {Revenue:1} the first.
// Aggregate functions (SUM/AVERAGE/MIN/MAX/COUNT) flatten array arguments;
// arithmetic and comparison operators are scalar-only (an array operand resolves
// to NaN). Comparisons yield 1/0; IF/AND/OR/NOT treat any nonzero, non-NaN value
// as true. compileFormula parses once and throws on a malformed formula. A
// compiled node is evaluated against a context: cell(name) gives the current-row
// scalar, column(name) gives the column across rows, row() the current row #. -mta
type FormulaValue = number | number[];
interface FormulaContext {
cell: (name: string) => number;
column: (name: string) => number[];
row: () => number;
}
type FormulaNode = (ctx: FormulaContext) => FormulaValue;
interface FormulaToken { t: string; v: string; }
const toScalar = (v: FormulaValue): number => Array.isArray(v) ? NaN : v;
const flattenValues = (vals: FormulaValue[]): number[] => {
const out: number[] = [];
for (const v of vals) {
if (Array.isArray(v)) { for (const n of v) out.push(n); }
else out.push(v);
}
return out;
};
// Index of the first ":" at brace/bracket/paren depth 0, or -1. Lets a column
// ref's name and its row index/range be split without tripping on a ":" inside a
// nested {ref} or function args.
const topLevelColon = (s: string): number => {
let depth = 0;
for (let k = 0; k < s.length; k++) {
const c = s[k];
if (c === "{" || c === "[" || c === "(") depth++;
else if (c === "}" || c === "]" || c === ")") depth--;
else if (c === ":" && depth === 0) return k;
}
return -1;
};
function tokenizeFormula(src: string): FormulaToken[] {
const tokens: FormulaToken[] = [];
const isDigit = (c: string) => c >= "0" && c <= "9";
const isAlpha = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_";
let i = 0;
while (i < src.length) {
const c = src[i];
if (c === " " || c === "\t" || c === "\n" || c === "\r") { i++; continue; }
if (c === "[") {
const end = src.indexOf("]", i + 1);
if (end < 0) throw new Error("Unclosed '[' reference");
tokens.push({ t: "ref", v: src.slice(i + 1, end).trim() });
i = end + 1;
continue;
}
if (c === "{") {
// Balanced-brace scan so a nested {Col:...} inside the index is kept
// intact (e.g. {A:{B:1}}).
let depth = 1, j = i + 1;
while (j < src.length && depth > 0) {
if (src[j] === "{") depth++;
else if (src[j] === "}") { depth--; if (depth === 0) break; }
j++;
}
if (depth !== 0) throw new Error("Unclosed '{' column reference");
tokens.push({ t: "colref", v: src.slice(i + 1, j).trim() });
i = j + 1;
continue;
}
if (isDigit(c) || (c === "." && isDigit(src[i + 1]))) {
let j = i + 1;
while (j < src.length && (isDigit(src[j]) || src[j] === ".")) j++;
tokens.push({ t: "num", v: src.slice(i, j) });
i = j;
continue;
}
if (isAlpha(c)) {
let j = i + 1;
while (j < src.length && (isAlpha(src[j]) || isDigit(src[j]))) j++;
tokens.push({ t: "id", v: src.slice(i, j) });
i = j;
continue;
}
const two = src.slice(i, i + 2);
if (two === "<=" || two === ">=" || two === "<>") { tokens.push({ t: "op", v: two }); i += 2; continue; }
if ("+-*/^%=<>".includes(c)) { tokens.push({ t: "op", v: c }); i++; continue; }
if ("(),".includes(c)) { tokens.push({ t: "punc", v: c }); i++; continue; }
throw new Error("Unexpected character '" + c + "'");
}
return tokens;
}
function makeFormulaFunction(name: string, args: FormulaNode[]): FormulaNode {
// Aggregate args flatten arrays (so {Col} spans rows); scalar args coerce.
const aggArgs = (ctx: FormulaContext) => flattenValues(args.map(a => a(ctx)));
const scalarArgs = (ctx: FormulaContext) => args.map(a => toScalar(a(ctx)));
const arg = (idx: number, ctx: FormulaContext) => args[idx] ? toScalar(args[idx](ctx)) : NaN;
const truthy = (n: number) => !Number.isNaN(n) && n !== 0;
switch (name) {
case "SUM": return (ctx) => applyCalcFunction("sum", aggArgs(ctx));
case "AVERAGE": case "AVG": return (ctx) => applyCalcFunction("average", aggArgs(ctx));
case "MEDIAN": return (ctx) => applyCalcFunction("median", aggArgs(ctx));
case "MODE": return (ctx) => applyCalcFunction("mode", aggArgs(ctx));
case "MIN": return (ctx) => applyCalcFunction("min", aggArgs(ctx));
case "MAX": return (ctx) => applyCalcFunction("max", aggArgs(ctx));
case "COUNT": return (ctx) => applyCalcFunction("count", aggArgs(ctx));
case "ABS": return (ctx) => Math.abs(arg(0, ctx));
case "ROUND": return (ctx) => { const f = Math.pow(10, args[1] ? arg(1, ctx) : 0); return Math.round(arg(0, ctx) * f) / f; };
case "FLOOR": return (ctx) => Math.floor(arg(0, ctx));
case "CEILING": case "CEIL": return (ctx) => Math.ceil(arg(0, ctx));
case "SQRT": return (ctx) => Math.sqrt(arg(0, ctx));
case "POWER": return (ctx) => Math.pow(arg(0, ctx), arg(1, ctx));
case "MOD": return (ctx) => { const b = arg(1, ctx); return b === 0 ? NaN : arg(0, ctx) % b; };
case "EXP": return (ctx) => Math.exp(arg(0, ctx));
case "LN": return (ctx) => Math.log(arg(0, ctx));
// LOG(n, [base]) — base defaults to 10, matching Excel.
case "LOG": return (ctx) => Math.log(arg(0, ctx)) / Math.log(args[1] ? arg(1, ctx) : 10);
// Trigonometry (angles in radians, like Excel; use RADIANS()/DEGREES() to convert).
case "SIN": return (ctx) => Math.sin(arg(0, ctx));
case "COS": return (ctx) => Math.cos(arg(0, ctx));
case "TAN": return (ctx) => Math.tan(arg(0, ctx));
case "ASIN": return (ctx) => Math.asin(arg(0, ctx));
case "ACOS": return (ctx) => Math.acos(arg(0, ctx));
case "ATAN": return (ctx) => Math.atan(arg(0, ctx));
// ATAN2(x, y) — Excel order (angle of the point (x, y)).
case "ATAN2": return (ctx) => Math.atan2(arg(1, ctx), arg(0, ctx));
case "SINH": return (ctx) => Math.sinh(arg(0, ctx));
case "COSH": return (ctx) => Math.cosh(arg(0, ctx));
case "TANH": return (ctx) => Math.tanh(arg(0, ctx));
case "PI": return () => Math.PI;
case "RADIANS": return (ctx) => arg(0, ctx) * Math.PI / 180;
case "DEGREES": return (ctx) => arg(0, ctx) * 180 / Math.PI;
case "IF": return (ctx) => truthy(arg(0, ctx)) ? arg(1, ctx) : (args[2] ? arg(2, ctx) : 0);
case "AND": return (ctx) => scalarArgs(ctx).every(truthy) ? 1 : 0;
case "OR": return (ctx) => scalarArgs(ctx).some(truthy) ? 1 : 0;
case "NOT": return (ctx) => truthy(arg(0, ctx)) ? 0 : 1;
// ROW(): the current row's 1-based position in the filtered set.
case "ROW": return (ctx) => ctx.row();
default: throw new Error("Unknown function '" + name + "'");
}
}
export function compileFormula(src: string): FormulaNode {
const tokens = tokenizeFormula(src);
if (tokens.length === 0) throw new Error("Empty formula");
let pos = 0;
const peek = () => tokens[pos];
const next = () => tokens[pos++];
const expect = (v: string) => { const t = next(); if (!t || t.v !== v) throw new Error("Expected '" + v + "'"); };
const isOp = (...ops: string[]) => peek() && peek().t === "op" && ops.includes(peek().v);
const parseComparison = (): FormulaNode => {
let left = parseAddSub();
while (isOp("=", "<>", "<", ">", "<=", ">=")) {
const op = next().v, l = left, r = parseAddSub();
left = (ctx) => {
const a = toScalar(l(ctx)), b = toScalar(r(ctx));
if (Number.isNaN(a) || Number.isNaN(b)) return 0;
switch (op) {
case "=": return a === b ? 1 : 0;
case "<>": return a !== b ? 1 : 0;
case "<": return a < b ? 1 : 0;
case ">": return a > b ? 1 : 0;
case "<=": return a <= b ? 1 : 0;
default: return a >= b ? 1 : 0;
}
};
}
return left;
};
const parseAddSub = (): FormulaNode => {
let left = parseMulDiv();
while (isOp("+", "-")) {
const op = next().v, l = left, r = parseMulDiv();
left = (ctx) => op === "+" ? toScalar(l(ctx)) + toScalar(r(ctx)) : toScalar(l(ctx)) - toScalar(r(ctx));
}
return left;
};
const parseMulDiv = (): FormulaNode => {
let left = parsePow();
while (isOp("*", "/", "%")) {
const op = next().v, l = left, r = parsePow();
left = (ctx) => {
const a = toScalar(l(ctx)), b = toScalar(r(ctx));
if (op === "*") return a * b;
return b === 0 ? NaN : (op === "/" ? a / b : a % b); // "%" = remainder
};
}
return left;
};
const parsePow = (): FormulaNode => {
const left = parseUnary();
if (isOp("^")) { next(); const r = parsePow(); return (ctx) => Math.pow(toScalar(left(ctx)), toScalar(r(ctx))); }
return left;
};
const parseUnary = (): FormulaNode => {
if (isOp("-")) { next(); const o = parseUnary(); return (ctx) => -toScalar(o(ctx)); }
if (isOp("+")) { next(); return parseUnary(); }
return parsePrimary();
};
const parsePrimary = (): FormulaNode => {
const t = peek();
if (!t) throw new Error("Unexpected end of formula");
if (t.t === "num") { next(); const v = parseFloat(t.v); return () => v; }
if (t.t === "ref") { next(); const name = t.v; return (ctx) => ctx.cell(name); }
if (t.t === "colref") {
next();
const raw = t.v;
// {Name} -> whole column (array); {Name:n} -> the nth (1-based) row
// (scalar); {Name:a:b} -> rows a..b inclusive (array, Excel range).
const ci = topLevelColon(raw);
if (ci < 0) { const name = raw.trim(); return (ctx) => ctx.column(name); }
const name = raw.slice(0, ci).trim();
const spec = raw.slice(ci + 1).trim();
const ri = topLevelColon(spec);
if (ri < 0) {
const idxNode = compileFormula(spec);
return (ctx) => {
const arr = ctx.column(name);
const n = toScalar(idxNode(ctx));
if (Number.isNaN(n)) return NaN;
const idx = Math.trunc(n) - 1;
return idx >= 0 && idx < arr.length ? arr[idx] : NaN;
};
}
const startNode = compileFormula(spec.slice(0, ri).trim());
const endNode = compileFormula(spec.slice(ri + 1).trim());
return (ctx) => {
const arr = ctx.column(name);
let s = toScalar(startNode(ctx)), e = toScalar(endNode(ctx));
if (Number.isNaN(s) || Number.isNaN(e)) return [];
s = Math.trunc(s); e = Math.trunc(e);
if (s > e) { const tmp = s; s = e; e = tmp; }
const out: number[] = [];
for (let k = s; k <= e; k++) { const i = k - 1; if (i >= 0 && i < arr.length) out.push(arr[i]); }
return out;
};
}
if (t.t === "punc" && t.v === "(") { next(); const e = parseComparison(); expect(")"); return e; }
if (t.t === "id") {
next();
// A bare identifier (no following "(") is a named constant (PI, E, …).
if (!(peek() && peek().t === "punc" && peek().v === "(")) {
const cv = FORMULA_CONSTANTS[t.v.toUpperCase()];
if (cv === undefined) throw new Error("Unknown name '" + t.v + "'");
return () => cv;
}
expect("(");
const args: FormulaNode[] = [];
if (!(peek() && peek().t === "punc" && peek().v === ")")) {
args.push(parseComparison());
while (peek() && peek().t === "punc" && peek().v === ",") { next(); args.push(parseComparison()); }
}
expect(")");
return makeFormulaFunction(t.v.toUpperCase(), args);
}
throw new Error("Unexpected token '" + t.v + "'");
};
const root = parseComparison();
if (pos < tokens.length) throw new Error("Unexpected token '" + tokens[pos].v + "'");
return root;
}
const escapeHtml = (s: string): string => s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Lexical syntax highlighter for the formula editor overlay. Returns HTML with
// colored spans for cell refs [..], column refs {..}, function names, numbers,
// and operators. Purely visual — mirrors the tokenizer's character rules.
function highlightFormula(src: string): string {
const isDigit = (c: string) => c >= "0" && c <= "9";
const isAlpha = (c: string) => (c >= "a" && c <= "z") || (c >= "A" && c <= "Z") || c === "_";
let out = "";
let i = 0;
while (i < src.length) {
const c = src[i];
if (c === "[") {
const end = src.indexOf("]", i + 1);
const j = end < 0 ? src.length : end + 1;
out += `<span class="text-sky-600 dark:text-sky-400">${escapeHtml(src.slice(i, j))}</span>`;
i = j; continue;
}
if (c === "{") {
let depth = 1, j = i + 1;
while (j < src.length && depth > 0) { if (src[j] === "{") depth++; else if (src[j] === "}") depth--; j++; }
out += `<span class="text-violet-600">${escapeHtml(src.slice(i, j))}</span>`;
i = j; continue;
}
if (isDigit(c) || (c === "." && isDigit(src[i + 1]))) {
let j = i + 1;
while (j < src.length && (isDigit(src[j]) || src[j] === ".")) j++;
out += `<span class="text-amber-600 dark:text-amber-400">${escapeHtml(src.slice(i, j))}</span>`;
i = j; continue;
}
if (isAlpha(c)) {
let j = i + 1;
while (j < src.length && (isAlpha(src[j]) || isDigit(src[j]))) j++;
const word = src.slice(i, j);
let k = j; while (k < src.length && src[k] === " ") k++;
out += src[k] === "("
? `<span class="text-emerald-700 dark:text-emerald-400 font-semibold">${escapeHtml(word)}</span>`
: (FORMULA_CONSTANTS[word.toUpperCase()] !== undefined ? `<span class="text-amber-600 dark:text-amber-400">${escapeHtml(word)}</span>` : escapeHtml(word));
i = j; continue;
}
if ("+-*/^%=<>(),:".includes(c)) { out += `<span class="text-ink-faint">${escapeHtml(c)}</span>`; i++; continue; }
out += escapeHtml(c); i++;
}
return out;
}
// Format a numeric result per its data type. Falls back to plain string when no
// data type is set. prefix/suffix wrap the formatted text.
function formatCalcResult(result: number, dataType: CalculatedDataType | undefined, precision: number | undefined, prefix: string | undefined, suffix: string | undefined, emptyValue: string | undefined): string {
if (!isFinite(result)) return emptyValue ?? "—";
let money = "";
let percent = "";
let text: string;
switch (dataType) {
case "money": money = "$"; text = formatDecimal(result, precision ?? 2); break;
case "decimal": text = formatDecimal(result, precision ?? 2); break;
case "integer": text = formatNumber(Math.round(result)); break;
case "percent": percent = "%"; text = result.toFixed(precision ?? 2); break;
case "number": text = typeof precision === "number" ? formatDecimal(result, precision) : formatNumber(result); break;
case "plain":
default: text = typeof precision === "number" ? result.toFixed(precision) : String(result);
}
// Never render a negative zero: once rounded to the display precision, a tiny
// negative like SIN(2*PI) ≈ -2.4e-16 would otherwise show as "-0"/"-0.00".
if (text.charAt(0) === "-" && parseFloat(text) === 0) text = text.slice(1);
return (prefix ?? "") + money + text + percent + (suffix ?? "");
}
// Evaluate a calculated column for a single row. A spec with a `compute` closure
// (user columns) defers to it; otherwise operands are read from row fields and
// combined by the predefined function. Returns NaN when not computable.
export function computeCalculatedValue(item: any, spec: CalculatedColumnSpec): number {
if (spec.compute) return spec.compute(item);
if (spec.fn === "custom") return NaN; // custom requires a `compute` closure
const operands = spec.operands.map(op => typeof op === "number" ? op : toCalcNumber(item?.[op]));
return applyCalcFunction(spec.fn, operands);
}
// Compute and format a calculated column's value for display/CSV/PDF.
export function formatCalculatedValue(item: any, spec: CalculatedColumnSpec): any {
const result = computeCalculatedValue(item, spec);
if (spec.format) return spec.format(result, item);
return formatCalcResult(result, spec.dataType, spec.precision, spec.prefix, spec.suffix, spec.emptyValue);
}
interface ProcessedData {
data: any[];
allFilteredData: any[];
pagination: AutoTablePagination;
}
// Apply the search/filter entries to raw rows (no sort/pagination). Shared by
// processDataLocally and the cross-row aggregate source so both see the same set.
function applySearchFilters(rawData: any[], searchEntries: AutoTableSearchEntry[]): any[] {
let processed = [...rawData];
for (const search of searchEntries) {
if (search.Values && search.Values.length > 0 && search.Values[0]) {
if (search.Values.length > 1) {
const matchSet = new Set(search.Values.map(v => String(v).toLowerCase()));
processed = processed.filter(item => {
const fieldValue = item[search.Identifier];
if (fieldValue === null || fieldValue === undefined) return false;
return matchSet.has(String(fieldValue).toLowerCase());
});
} else {
const searchValue = search.Values[0].toLowerCase();
const match = search.Exact
? (val: any) => String(val).toLowerCase() === searchValue
: (val: any) => String(val).toLowerCase().includes(searchValue);
if (isMultiSearchIdentifier(search.Identifier)) {
const fields = parseMultiSearchFields(search.Identifier);
processed = processed.filter(item => {
return fields.some(field => {
const fieldValue = item[field];
if (fieldValue === null || fieldValue === undefined) return false;
return match(fieldValue);
});
});
} else {
processed = processed.filter(item => {
const fieldValue = item[search.Identifier];
if (fieldValue === null || fieldValue === undefined) return false;
return match(fieldValue);
});
}
}
}
}
return processed;
}
function processDataLocally(rawData: any[], filter: AutoTableFilter, columns: AutoTableColumn[]): ProcessedData {
let processed = applySearchFilters(rawData, filter.Search);
const totalItems = processed.length;
if (filter.OrderBy.Identifier) {
let sortKey: string | null = null;
let column: AutoTableColumn | undefined;
const colIndexMatch = filter.OrderBy.Identifier.match(/^_col_(\d+)$/);
if (colIndexMatch) {
const colIndex = parseInt(colIndexMatch[1], 10);
column = columns[colIndex];
if (column?.sortIdentifier) {
sortKey = column.sortIdentifier;
} else if (!column?.calculated && processed.length > 0) {
// Calculated columns derive their sort value from the spec below,
// not from a row field, so skip the field-key derivation for them.
const keys = Object.keys(processed[0]);
if (colIndex < keys.length) {
sortKey = keys[colIndex];
}
}
} else {
sortKey = filter.OrderBy.Identifier;
column = columns.find((c) => c.sortIdentifier === sortKey);
}
if (sortKey || column?.calculated) {
const finalSortKey = sortKey;
// Calculated columns sort by their computed number (NaN sorts last
// via the null handling below) unless an explicit sortValue is given.
const calcSpec = column?.calculated;
const sortValue = column?.sortValue
?? (calcSpec ? (r: any) => { const n = computeCalculatedValue(r, calcSpec); return Number.isNaN(n) ? null : n; } : undefined);
// Extract the comparison value into a stable key so the column's
// sortType (e.g. "numeric") and any custom sortValue
// drive the order via the shared comparator. Falling back to the raw
// field keeps the prior type-aware behavior for untyped columns.
const getVal = typeof sortValue === "function" ? sortValue : (r: any) => r[finalSortKey];
const sortType = column?.sortType;
processed.sort((a, b) => {
const av = getVal(a);
const bv = getVal(b);
// Empty/null always sort last, regardless of direction (matches
// the prior behavior and the CellGrid comparator).
if (av === null || av === undefined) return 1;
if (bv === null || bv === undefined) return -1;
let comparison: number;
if (!sortType && typeof av === "number" && typeof bv === "number") {
comparison = av - bv;
} else if (!sortType && av instanceof Date && bv instanceof Date) {
comparison = av.getTime() - bv.getTime();
} else {
comparison = compareRowsGeneric({ v: av }, { v: bv }, "v", sortType);
}
return filter.OrderBy.Descending ? -comparison : comparison;
});
}
}
const maxItems = filter.Pagination.MaxItemsPerPage;
const totalPages = maxItems === -1 ? 1 : Math.max(1, Math.ceil(totalItems / maxItems));
const currentPage = Math.min(filter.Pagination.CurrentPage, totalPages);
let paginatedData: any[];
let viewRangeLower: number, viewRangeUpper: number;
if (maxItems === -1) {
paginatedData = processed;
viewRangeLower = totalItems > 0 ? 1 : 0;
viewRangeUpper = totalItems;
} else {
const startIdx = (currentPage - 1) * maxItems;
const endIdx = startIdx + maxItems;
paginatedData = processed.slice(startIdx, endIdx);
viewRangeLower = totalItems > 0 ? startIdx + 1 : 0;
viewRangeUpper = Math.min(endIdx, totalItems);
}
return {
data: paginatedData,
allFilteredData: processed,
pagination: {
CurrentPage: currentPage,
TotalPages: totalPages,
TotalItems: totalItems,
MaxItemsPerPage: maxItems,
ViewRangeLower: viewRangeLower,
ViewRangeUpper: viewRangeUpper,
},
};
}
function downloadCSV(data: any[], columns: AutoTableColumn[], filename: string): void {
if (data.length === 0) return;
const csvColumns = columns.filter(col => col.csv && (col.csvValue || col.calculated));
if (csvColumns.length === 0) return;
const escapeCSV = (value: any): string => {
if (value === null || value === undefined) return "";
const str = String(value);
if (str.includes(",") || str.includes('"') || str.includes("\n")) {
return '"' + str.replace(/"/g, '""') + '"';
}
return str;
};
const cellValue = (col: AutoTableColumn, item: any): any =>
col.csvValue ? col.csvValue(item) : formatCalculatedValue(item, col.calculated!);
const headers = csvColumns.map(col => escapeCSV(col.displayName)).join(",");
const rows = data.map(item => {
return csvColumns.map(col => escapeCSV(cellValue(col, item))).join(",");
});
const csvContent = [headers, ...rows].join("\n");
const blob = new Blob([csvContent], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename + ".csv";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
async function buildTablePDF(data: any[], columns: AutoTableColumn[], pdfHeader: AutoTablePDFHeader | undefined, response: any, summaries: { label: string; value: string }[] = []) {
const pdfColumns = columns.filter(col => col.csv && (col.csvValue || col.calculated));
if (data.length === 0 || pdfColumns.length === 0) return null;
const pdfCellValue = (col: AutoTableColumn, item: any): any =>
col.csvValue ? col.csvValue(item) : formatCalculatedValue(item, col.calculated!);
const header = {
title: "",
subtitle: "",
showDate: true,
logoUrl: "/images/logo_black.png",
showLogo: true,
orientation: PDF_ORIENTATION_LANDSCAPE,
...pdfHeader,
};
const pdf = await PDFDocument.create();
const font = await pdf.embedFont(StandardFonts.Helvetica);
const fontBold = await pdf.embedFont(StandardFonts.HelveticaBold);
const fontHeading = fontBold;
const PAGE_MARGIN = 40;
const ROW_HEIGHT = 18;
const COL_HEADER_HEIGHT = 22;
const FONT_SIZE = 8;
const COL_HEADER_FONT_SIZE = 9;
const isPortrait = header.orientation === PDF_ORIENTATION_PORTRAIT;
const PAGE_WIDTH = isPortrait ? 612 : 792;
const PAGE_HEIGHT = isPortrait ? 792 : 612;
const CONTENT_WIDTH = PAGE_WIDTH - PAGE_MARGIN * 2;
const HEADER_BG = rgb(1, 1, 1);
const HEADER_TEXT_COLOR = rgb(0.1, 0.1, 0.1);
const ROW_ALT_BG = rgb(0.95, 0.95, 0.95);
const BORDER_COLOR = rgb(0.8, 0.8, 0.8);
const TEXT_COLOR = rgb(0.1, 0.1, 0.1);
const MUTED_COLOR = rgb(0.5, 0.5, 0.5);
// Embed logo
let logoImage: any = null;
if (header.showLogo && header.logoUrl) {
try {
const logoBytes = await fetch(header.logoUrl).then((r) => r.arrayBuffer());
logoImage = await pdf.embedPng(new Uint8Array(logoBytes));
} catch {
// Logo not available
}
}
const colWidths = pdfColumns.map(col => {
const headerLen = col.displayName.length;
let maxDataLen = headerLen;
for (let i = 0; i < Math.min(data.length, 50); i++) {
const val = pdfCellValue(col, data[i]);
const len = val === null || val === undefined ? 0 : String(val).length;
if (len > maxDataLen) maxDataLen = len;
}
return Math.max(maxDataLen, 4);
});
const totalWeight = colWidths.reduce((sum, w) => sum + w, 0);
const colActualWidths = colWidths.map(w => (w / totalWeight) * CONTENT_WIDTH);
const truncateText = (text: any, maxWidth: number, f: any, size: number): string => {
if (!text) return "";
let str = String(text);
let w = f.widthOfTextAtSize(str, size);
if (w <= maxWidth) return str;
while (str.length > 0 && w > maxWidth) {
str = str.slice(0, -1);
w = f.widthOfTextAtSize(str + "…", size);
}
return str + "…";
};
let page: any = null;
let y = 0;
let isFirstPage = true;
const drawPageHeader = () => {
const hasHeader = header.title || header.subtitle || header.showDate;
if (!isFirstPage || !hasHeader) return;
const TITLE_SIZE = 12;
const SUBTITLE_SIZE = 9;
const DATE_SIZE = 9;
// Right-aligned date (the logo is drawn in the page footer, not here).
if (header.showDate) {
const now = new Date();
const dateStr = now.toLocaleDateString("en-US", { year: "numeric", month: "long", day: "numeric" });
const dateWidth = font.widthOfTextAtSize(dateStr, DATE_SIZE);
page.drawText(dateStr, {
x: PAGE_MARGIN + CONTENT_WIDTH - dateWidth,
y: y - DATE_SIZE - 2,
size: DATE_SIZE, font, color: MUTED_COLOR,
});
}
let rightBlockHeight = header.showDate ? DATE_SIZE + 2 : 0;
// Title on the left
if (header.title) {
page.drawText(header.title, {
x: PAGE_MARGIN,
y: y - TITLE_SIZE,
size: TITLE_SIZE, font: fontHeading, color: TEXT_COLOR,
});
}
let blockHeight = Math.max(rightBlockHeight, header.title ? TITLE_SIZE + 6 : 0);
let leftBlockHeight = header.title ? TITLE_SIZE + 6 : 0;
if (header.subtitle) {
page.drawText(header.subtitle, {
x: PAGE_MARGIN,
y: y - leftBlockHeight - SUBTITLE_SIZE - 4,
size: SUBTITLE_SIZE, font, color: MUTED_COLOR,
});
leftBlockHeight += SUBTITLE_SIZE + 4;
blockHeight = Math.max(blockHeight, leftBlockHeight);
}
if (blockHeight > 0) {
y -= blockHeight + 16;
}
// Divider line
page.drawLine({
start: { x: PAGE_MARGIN, y },
end: { x: PAGE_MARGIN + CONTENT_WIDTH, y },
thickness: 0.75, color: BORDER_COLOR,
});
y -= 12;
};
const drawColumnHeaders = () => {
let x = PAGE_MARGIN;
for (let i = 0; i < pdfColumns.length; i++) {
const text = pdfColumns[i].displayName;
const truncated = truncateText(text, colActualWidths[i] - 6, fontBold, COL_HEADER_FONT_SIZE);
page.drawText(truncated, {
x: x + 4, y: y - COL_HEADER_HEIGHT + 7,
size: COL_HEADER_FONT_SIZE, font: fontBold, color: HEADER_TEXT_COLOR,
});
x += colActualWidths[i];
}
y -= COL_HEADER_HEIGHT;
page.drawLine({
start: { x: PAGE_MARGIN, y },
end: { x: PAGE_MARGIN + CONTENT_WIDTH, y },
thickness: 1, color: rgb(0.15, 0.15, 0.15),
});
};
const addPage = () => {
page = pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
y = PAGE_HEIGHT - PAGE_MARGIN;
drawPageHeader();
drawColumnHeaders();
isFirstPage = false;
};
addPage();
for (let rowIdx = 0; rowIdx < data.length; rowIdx++) {
if (y - ROW_HEIGHT < PAGE_MARGIN) {
addPage();
}
if (rowIdx % 2 === 1) {
page.drawRectangle({
x: PAGE_MARGIN, y: y - ROW_HEIGHT,
width: CONTENT_WIDTH, height: ROW_HEIGHT,
color: ROW_ALT_BG,
});
}
page.drawLine({
start: { x: PAGE_MARGIN, y: y - ROW_HEIGHT },
end: { x: PAGE_MARGIN + CONTENT_WIDTH, y: y - ROW_HEIGHT },
thickness: 0.5, color: BORDER_COLOR,
});
let x = PAGE_MARGIN;
for (let i = 0; i < pdfColumns.length; i++) {
const raw = pdfCellValue(pdfColumns[i], data[rowIdx]);
const text = raw === null || raw === undefined ? "" : String(raw);
const truncated = truncateText(text, colActualWidths[i] - 6, font, FONT_SIZE);
page.drawText(truncated, {
x: x + 4, y: y - ROW_HEIGHT + 6,
size: FONT_SIZE, font, color: TEXT_COLOR,
});
x += colActualWidths[i];
}
y -= ROW_HEIGHT;
}
// Footer summary rows (Total, Subtotal, …): a subtle shaded band under a
// divider, emphasized label and value right-aligned in the last column —
// mirroring the on-screen <tfoot>.
if (summaries.length > 0) {
// Left edge x of each column.
const colX: number[] = [];
{ let xx = PAGE_MARGIN; for (let i = 0; i < pdfColumns.length; i++) { colX[i] = xx; xx += colActualWidths[i]; } }
// Draw text with its right edge at `rightX`; returns the text width.
const drawRightEdge = (text: string, rightX: number, fnt: any): number => {
const w = fnt.widthOfTextAtSize(text, FONT_SIZE);
page.drawText(text, { x: rightX - w, y: y - ROW_HEIGHT + 6, size: FONT_SIZE, font: fnt, color: TEXT_COLOR });
return w;
};
const cellRight = (colIdx: number) => colX[colIdx] + colActualWidths[colIdx] - 4;
page.drawLine({
start: { x: PAGE_MARGIN, y },
end: { x: PAGE_MARGIN + CONTENT_WIDTH, y },
thickness: 1, color: rgb(0.55, 0.55, 0.55),
});
for (const s of summaries) {
if (y - ROW_HEIGHT < PAGE_MARGIN) addPage();
page.drawRectangle({
x: PAGE_MARGIN, y: y - ROW_HEIGHT,
width: CONTENT_WIDTH, height: ROW_HEIGHT,
color: rgb(0.96, 0.96, 0.96),
});
page.drawLine({
start: { x: PAGE_MARGIN, y: y - ROW_HEIGHT },
end: { x: PAGE_MARGIN + CONTENT_WIDTH, y: y - ROW_HEIGHT },
thickness: 0.5, color: BORDER_COLOR,
});
// Always right-aligned in the last column; only the label (title) is bold.
const valueColIdx = pdfColumns.length - 1;
if (valueColIdx > 0) {
drawRightEdge(s.value, cellRight(valueColIdx), font); // value in the last column
drawRightEdge(s.label, cellRight(valueColIdx - 1), fontBold); // label in the cell to the left
} else {
// Single-column table: share the one cell (value at the right, label just left of it).
const right = cellRight(0);
const vw = drawRightEdge(s.value, right, font);
drawRightEdge(s.label, right - vw - 8, fontBold);
}
y -= ROW_HEIGHT;
}
}
// Draw belowTable content if provided
if (header.belowTable) {
await header.belowTable({
page,
y,
pageWidth: PAGE_WIDTH,
pageHeight: PAGE_HEIGHT,
margin: PAGE_MARGIN,
contentWidth: CONTENT_WIDTH,
font,
fontBold,
colors: { text: TEXT_COLOR, muted: MUTED_COLOR, border: BORDER_COLOR },
response,
pdf,
addPage: () => {
page = pdf.addPage([PAGE_WIDTH, PAGE_HEIGHT]);
y = PAGE_HEIGHT - PAGE_MARGIN;
return { page, y };
},
});
}
// Logo footer dimensions (drawn bottom-left of every page).
const FOOTER_LOGO_MAX_HEIGHT = 18;
const FOOTER_LOGO_MAX_WIDTH = 70;
let footerLogoWidth = 0;
let footerLogoHeight = 0;
if (logoImage) {
const aspect = logoImage.width / logoImage.height;
footerLogoHeight = Math.min(FOOTER_LOGO_MAX_HEIGHT, logoImage.height);
footerLogoWidth = footerLogoHeight * aspect;
if (footerLogoWidth > FOOTER_LOGO_MAX_WIDTH) {
footerLogoWidth = FOOTER_LOGO_MAX_WIDTH;
footerLogoHeight = footerLogoWidth / aspect;
}
}
const pages = pdf.getPages();
for (let i = 0; i < pages.length; i++) {
// Page number bottom-left, logo bottom-right.
const footerText = "Page " + (i + 1) + " of " + pages.length;
pages[i].drawText(footerText, {
x: PAGE_MARGIN,
y: PAGE_MARGIN - 20,
size: 8, font, color: MUTED_COLOR,
});
if (logoImage) {
pages[i].drawImage(logoImage, {
x: PAGE_WIDTH - PAGE_MARGIN - footerLogoWidth,
y: PAGE_MARGIN - 18 - (footerLogoHeight - 8) / 2,
width: footerLogoWidth,
height: footerLogoHeight,
});
}
}
return await pdf.save();
}
async function downloadPDF(data: any[], columns: AutoTableColumn[], filename: string, pdfHeader: AutoTablePDFHeader | undefined, response: any, summaries: { label: string; value: string }[] = []): Promise<void> {
const pdfBytes = await buildTablePDF(data, columns, pdfHeader, response, summaries);
if (!pdfBytes) return;
const blob = new Blob([pdfBytes as BlobPart], { type: "application/pdf" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename + ".pdf";
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
interface FormulaSpec { fn: string; operands: string[]; formula: string; }
interface FormulaFieldApi { setSpec: (spec: FormulaSpec) => void; }
interface FormulaFieldProps {
// "column": Basic functions combine the chosen columns per row (column-based).
// "summary": Basic functions aggregate one column down the rows (row-based).
mode?: "column" | "summary";
onchange: (spec: FormulaSpec) => void; // fired on every edit
operandOptions: () => FormSelectOption[]; // column choices + caret-name → column mapping
onHighlight?: (keys: string[] | null) => void; // highlight the affected column(s)
apiRef?: (api: FormulaFieldApi) => void; // parent uses api.setSpec to seed/reset
}
// The calculation editor, shared by the calculated-column and summary-row forms.
// Basic mode: a predefined function + column select(s). Advanced mode ("custom"):
// an Excel-style formula in a single-line textarea (UNCONTROLLED, overlay repainted
// IMPERATIVELY so typing never loses focus) with a "Function" menu and insert chips.
// The current state is reported to the parent as a {fn, operands, formula} spec; the
// parent seeds it through api.setSpec when its popover opens.
function FormulaField(props: FormulaFieldProps) {
const ctx = useFloatingContext();
const [funcMenuOpen, setFuncMenuOpen] = createSignal(false);
const [funcSearch, setFuncSearch] = createSignal("");
let funcSearchInput: HTMLInputElement | undefined;
const [colMenuOpen, setColMenuOpen] = createSignal(false);
const [colSearch, setColSearch] = createSignal("");
let colSearchInput: HTMLInputElement | undefined;
const [constMenuOpen, setConstMenuOpen] = createSignal(false);
const [constSearch, setConstSearch] = createSignal("");
let constSearchInput: HTMLInputElement | undefined;
const [fn, setFn] = createSignal("sum"); // "custom" === advanced
const [formula, setFormula] = createSignal("");
const [multiOperands, setMultiOperands] = createSignal([] as string[]);
const [operandA, setOperandA] = createSignal("");
const [operandB, setOperandB] = createSignal("");
const [singleOperand, setSingleOperand] = createSignal("");
let ta: HTMLTextAreaElement | undefined;
let overlay: HTMLDivElement | undefined;
const mode = () => (typeof props.mode === "function" ? (props.mode as () => string)() : props.mode) || "column";
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);
const isAdvanced = () => fn() === "custom";
const isBinary = () => isBinaryCalcFn(fn());
const functionOptions = () => mode() === "summary" ? SUMMARY_FUNCTION_OPTIONS : CALC_FUNCTION_OPTIONS;
const currentOperands = (): string[] => {
if (isAdvanced()) return [];
if (mode() === "summary") return singleOperand() ? [singleOperand()] : [];
if (isBinary()) return [operandA(), operandB()].filter(Boolean);
return multiOperands();
};
const emit = () => props.onchange({ fn: fn(), operands: currentOperands(), formula: isAdvanced() ? formula() : "" });
const renderOverlay = () => { if (overlay && ta) overlay.innerHTML = ta.value.trim() ? highlightFormula(ta.value) : FORMULA_PLACEHOLDER; };
const syncScroll = () => { if (overlay && ta) { overlay.scrollLeft = ta.scrollLeft; overlay.scrollTop = ta.scrollTop; } };
// Highlight the table column whose [Col]/{Col...} reference the caret sits in.
const refNameAtCaret = (text: string, pos: number): string | null => {
let i = 0;
while (i < text.length) {
if (text[i] === "[") {
const end = text.indexOf("]", i + 1);
const close = end < 0 ? text.length : end;
if (pos >= i && pos <= close) return text.slice(i + 1, close).trim();
i = close + 1; continue;
}
if (text[i] === "{") {
let depth = 1, j = i + 1;
while (j < text.length && depth > 0) { if (text[j] === "{") depth++; else if (text[j] === "}") depth--; j++; }
const close = depth === 0 ? j - 1 : text.length;
if (pos >= i && pos <= close) return text.slice(i + 1, close).split(":")[0].trim();
i = close + 1; continue;
}
i++;
}
return null;
};
const updateCaretHighlight = () => {
if (!ta) { highlight(null); return; }
const name = refNameAtCaret(ta.value, ta.selectionStart ?? 0);
const lc = (name ?? "").toLowerCase();
const match = name ? operandOptions().find(o => o.label.toLowerCase() === lc) : null;
highlight(match ? [match.value] : null);
};
const onFormulaInput = (v: string) => { setFormula(v); emit(); renderOverlay(); syncScroll(); updateCaretHighlight(); };
// Insert text at the caret (replacing any selection), then restore focus.
const insertAt = (build: (cur: string, start: number, end: number) => { next: string; caret: number }) => {
if (!ta) return;
const cur = ta.value;
const start = ta.selectionStart ?? cur.length;
const end = ta.selectionEnd ?? cur.length;
const { next, caret } = build(cur, start, end);
ta.value = next; setFormula(next); emit(); renderOverlay();
requestAnimationFrame(() => { if (ta) { ta.focus(); ta.setSelectionRange(caret, caret); syncScroll(); updateCaretHighlight(); } });
};
const insertRef = (text: string) => insertAt((cur, start, end) => ({ next: cur.slice(0, start) + text + cur.slice(end), caret: start + text.length }));
// NAME(...) — any current selection becomes the first argument, else caret lands inside the parens.
const insertFunction = (name: string) => insertAt((cur, start, end) => {
const selected = cur.slice(start, end);
const insert = name + "(" + selected + ")";
return { next: cur.slice(0, start) + insert + cur.slice(end), caret: selected ? start + insert.length : start + name.length + 1 };
});
const setSpec = (s: FormulaSpec) => {
setFn(s.fn || "sum");
const ops = s.operands ?? [];
setSingleOperand(ops[0] ?? "");
setOperandA(ops[0] ?? "");
setOperandB(ops[1] ?? "");
setMultiOperands(ops);
setFormula(s.formula ?? "");
if (ta) { ta.value = s.formula ?? ""; renderOverlay(); }
emit();
};
if (props.apiRef) props.apiRef({ setSpec });
const setMode = (advanced: boolean) => { setFn(advanced ? "custom" : functionOptions()[0].value); highlight(null); emit(); };
const chooseFn = (v: string) => { setFn(v); emit(); };
// Function menu, filtered by the search box (matches name, signature or description).
const filteredFunctionGroups = () => {
const q = funcSearch().trim().toLowerCase();
if (!q) return FORMULA_FUNCTION_GROUPS;
return FORMULA_FUNCTION_GROUPS
.map(g => ({ label: g.label, fns: g.fns.filter(f => (f.name + " " + f.sig + " " + f.desc).toLowerCase().includes(q)) }))
.filter(g => g.fns.length > 0);
};
// Reset the search when the menu closes; focus it when it opens.
createEffect(() => {
if (funcMenuOpen()) requestAnimationFrame(() => funcSearchInput?.focus());
else setFuncSearch("");
});
// Column insert menu, filtered by its search box (matches the column name).
const filteredColumns = () => {
const q = colSearch().trim().toLowerCase();
const all = operandOptions;
return q ? all().filter(o => o.label.toLowerCase().includes(q)) : all();
};
createEffect(() => {
if (colMenuOpen()) requestAnimationFrame(() => colSearchInput?.focus());
else setColSearch("");
});
// Constant menu, filtered by its search box (matches name or description).
const filteredConstants = () => {
const q = constSearch().trim().toLowerCase();
return q ? FORMULA_CONSTANT_OPTIONS.filter(c => (c.name + " " + c.desc).toLowerCase().includes(q)) : FORMULA_CONSTANT_OPTIONS;
};
createEffect(() => {
if (constMenuOpen()) requestAnimationFrame(() => constSearchInput?.focus());
else setConstSearch("");
});
// Clear the table highlight and close the insert menus when the popover closes.
createEffect(() => { if (!ctx.isOpen()) { highlight(null); setFuncMenuOpen(false); setColMenuOpen(false); setConstMenuOpen(false); } });
const basicEditor = () => <div class="flex flex-col gap-2"
onMouseEnter={() => highlight(currentOperands())} onMouseLeave={() => highlight(null)}>
<FormSelect small={true} class="w-full" value={fn()}
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => chooseFn(e.currentTarget.value)}>
<For each={functionOptions()}>
{(f: { value: string; label: string }) => <option value={f.value}>{f.label}</option>}
</For>
</FormSelect>
<Show when={mode() === "summary"}>
<FormCombobox small={true} class="w-full" searchable={true}
options={operandOptions()} value={singleOperand()}
onchange={(v: string) => { setSingleOperand(v); emit(); }} placeholder="Column…" />
</Show>
<Show when={mode() !== "summary" && isBinary()}>
<div class="grid grid-cols-2 gap-2">
<FormCombobox small={true} class="w-full" searchable={true}
options={operandOptions()} value={operandA()}
onchange={(v: string) => { setOperandA(v); emit(); }} placeholder={fn() === "divide" ? "Numerator…" : "Value…"} />
<FormCombobox small={true} class="w-full" searchable={true}
options={operandOptions()} value={operandB()}
onchange={(v: string) => { setOperandB(v); emit(); }} placeholder={fn() === "divide" ? "Denominator…" : "Minus…"} />
</div>
</Show>
<Show when={mode() !== "summary" && !isBinary()}>
<FormMultiSelect small={true} class="w-full" searchable={true}
options={operandOptions()} value={multiOperands()}
onchange={(v: string[]) => { setMultiOperands(v); emit(); }} placeholder="Select columns…" />
</Show>
</div>;
// -- Insert menus (Advanced mode), shown in a row above the formula input ----
const MENU_TRIGGER = "inline-flex items-center gap-1 px-2 py-1 text-xs rounded-default border border-line-strong bg-surface-muted hover:bg-surface-raised text-ink cursor-pointer leading-none";
const MENU_SEARCH = "w-full text-xs p-1 border border-line-strong rounded-default focus:outline-2 focus:outline-sky-500 focus:outline-offset-1";
const columnMenu = () => <Show when={operandOptions.length}>
<Popover placement="bottom-start" standalone={true} open={colMenuOpen()} onOpenChange={setColMenuOpen}>
<PopoverTrigger class={MENU_TRIGGER}>Column <Icon icon="caret-down" size={12} solid={true} /></PopoverTrigger>
<PopoverContent class="w-64 max-h-72 overflow-y-auto">
<div class="sticky top-0 z-10 bg-surface border-b border-line p-1.5">
<input ref={(el: HTMLInputElement) => { colSearchInput = el; }} type="text" spellcheck="false"
placeholder="Search columns…" value={colSearch()}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setColSearch(e.currentTarget.value)}
class={MENU_SEARCH} />
</div>
<div class="p-1.5">
<For each={filteredColumns()}>
{(o: FormSelectOption) => <div
class="flex items-center rounded-default hover:bg-surface-raised"
onMouseEnter={() => highlight([o.value])} onMouseLeave={() => highlight(null)}>
<button type="button"
class="flex-1 min-w-0 text-left truncate px-2 py-1 font-mono text-xs text-sky-600 dark:text-sky-400 bg-transparent border-0 cursor-pointer"
title={"Insert [" + o.label + "] — this row's cell"}
onclick={(_e: MouseEvent) => { insertRef("[" + o.label + "]"); setColMenuOpen(false); }}>[{o.label}]</button>
<button type="button"
class="px-2 py-1 text-xs font-mono text-violet-600 hover:text-violet-800 bg-transparent border-0 cursor-pointer"
title={"Insert {" + o.label + "} — whole column"}
onclick={(_e: MouseEvent) => { insertRef("{" + o.label + "}"); setColMenuOpen(false); }}>{"{ }"}</button>
</div>}
</For>
<Show when={filteredColumns().length === 0}>
<div class="px-2 py-3 text-xs text-ink-muted text-center">No columns match.</div>
</Show>
</div>
</PopoverContent>
</Popover>
</Show>;
const functionMenu = () => <Popover placement="bottom-start" standalone={true} open={funcMenuOpen()} onOpenChange={setFuncMenuOpen}>
<PopoverTrigger class={MENU_TRIGGER}>Function <Icon icon="caret-down" size={12} solid={true} /></PopoverTrigger>
<PopoverContent class="w-64 max-h-72 overflow-y-auto">
<div class="sticky top-0 z-10 bg-surface border-b border-line p-1.5">
<input ref={(el: HTMLInputElement) => { funcSearchInput = el; }} type="text" spellcheck="false"
placeholder="Search functions…" value={funcSearch()}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setFuncSearch(e.currentTarget.value)}
class={MENU_SEARCH} />
</div>
<div class="p-1.5">
<For each={filteredFunctionGroups()}>
{(g: { label: string; fns: { name: string; sig: string; desc: string }[] }) => <div>
<div class="px-2 pt-1.5 pb-0.5 text-[10px] font-semibold uppercase tracking-wide text-ink-faint">{g.label}</div>
<For each={g.fns}>
{(f: { name: string; sig: string; desc: string }) => <button type="button"
class="w-full text-left px-2 py-1 rounded-default hover:bg-surface-raised cursor-pointer border-0 bg-transparent flex flex-col gap-0.5"
onclick={(_e: MouseEvent) => { insertFunction(f.name); setFuncMenuOpen(false); }}>
<span class="font-mono text-xs text-ink" innerHTML={highlightFormula(f.sig)}></span>
<span class="text-[11px] text-ink-muted">{f.desc}</span>
</button>}
</For>
</div>}
</For>
<Show when={filteredFunctionGroups().length === 0}>
<div class="px-2 py-3 text-xs text-ink-muted text-center">No functions match.</div>
</Show>
</div>
</PopoverContent>
</Popover>;
const constantMenu = () => <Popover placement="bottom-start" standalone={true} open={constMenuOpen()} onOpenChange={setConstMenuOpen}>
<PopoverTrigger class={MENU_TRIGGER}>Constant <Icon icon="caret-down" size={12} solid={true} /></PopoverTrigger>
<PopoverContent class="w-64 max-h-72 overflow-y-auto">
<div class="sticky top-0 z-10 bg-surface border-b border-line p-1.5">
<input ref={(el: HTMLInputElement) => { constSearchInput = el; }} type="text" spellcheck="false"
placeholder="Search constants…" value={constSearch()}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setConstSearch(e.currentTarget.value)}
class={MENU_SEARCH} />
</div>
<div class="p-1.5">
<For each={filteredConstants()}>
{(c: { name: string; desc: string }) => <button type="button"
class="w-full text-left px-2 py-1 rounded-default hover:bg-surface-raised cursor-pointer border-0 bg-transparent flex items-center justify-between gap-2"
onclick={(_e: MouseEvent) => { insertRef(c.name); setConstMenuOpen(false); }}>
<span class="font-mono text-xs text-amber-600 dark:text-amber-400">{c.name}</span>
<span class="text-[11px] text-ink-muted">{c.desc}</span>
</button>}
</For>
<Show when={filteredConstants().length === 0}>
<div class="px-2 py-3 text-xs text-ink-muted text-center">No constants match.</div>
</Show>
</div>
</PopoverContent>
</Popover>;
const advancedEditor = () => <div>
<div class="relative">
<div ref={(el: HTMLDivElement) => { overlay = el; }} class={FORMULA_OVERLAY_CLS} aria-hidden="true"></div>
<textarea ref={(el: HTMLTextAreaElement) => { ta = el; el.rows = 1; el.value = untrack(formula); renderOverlay(); }}
class={FORMULA_TEXTAREA_CLS} spellcheck="false" rows="1" wrap="off"
oninput={(e: InputEvent & { currentTarget: HTMLTextAreaElement }) => onFormulaInput(e.currentTarget.value)}
onKeyUp={updateCaretHighlight}
onclick={updateCaretHighlight}
onBlur={() => highlight(null)}
onScroll={syncScroll}></textarea>
</div>
<div class="flex flex-wrap items-center gap-1.5 mt-2">
{columnMenu()}
{functionMenu()}
{constantMenu()}
</div>
</div>;
return <div>
<div class="flex items-center gap-1.5 mb-2">
<span class="text-ink text-sm font-medium leading-none">Formula</span>
<Show when={isAdvanced()}>
<HoverPopover placement="top" standalone={true}>
<HoverPopoverTrigger class="inline-flex items-center text-ink-faint hover:text-ink-soft cursor-help bg-transparent border-0 p-0 leading-none">
<Icon icon="circle-info" size={14} />
</HoverPopoverTrigger>
<HoverPopoverContent class="p-3 w-80 text-xs text-ink-soft leading-relaxed flex flex-col gap-2">
<div>
<div class="font-medium text-ink mb-1">References</div>
<div class="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 items-center">
<code class="bg-surface-raised px-1 rounded justify-self-start">[Col]</code><span>this row's cell</span>
<code class="bg-surface-raised px-1 rounded justify-self-start">{"{Col}"}</code><span>the whole column</span>
<code class="bg-surface-raised px-1 rounded justify-self-start">{"{Col:n}"}</code><span>value in row n</span>
<code class="bg-surface-raised px-1 rounded justify-self-start">{"{Col:a:b}"}</code><span>rows a through b</span>
</div>
</div>
<div class="mt-2">
<div class="font-medium text-ink mb-1">Operators</div>
<div class="grid grid-cols-[max-content_1fr] gap-x-3 gap-y-1 items-center">
<code class="bg-surface-raised px-1 rounded justify-self-start">{"+ - * /"}</code><span>add, subtract, multiply, divide</span>
<code class="bg-surface-raised px-1 rounded justify-self-start">^</code><span>power (a to the b)</span>
<code class="bg-surface-raised px-1 rounded justify-self-start">%</code><span>remainder (modulo)</span>
<code class="bg-surface-raised px-1 rounded justify-self-start">{"= <> < > <= >="}</code><span>compare (yields 1 or 0)</span>
</div>
</div>
</HoverPopoverContent>
</HoverPopover>
</Show>
<SegmentedButtons class="ml-auto shrink-0" small={true}
options={[{ value: "basic", label: "Basic" }, { value: "advanced", label: "Advanced" }]}
value={isAdvanced() ? "advanced" : "basic"}
onchange={(v: string) => setMode(v === "advanced")} />
</div>
<Show when={isAdvanced()} fallback={basicEditor()}>
{advancedEditor()}
</Show>
</div>;
}
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 <div class="flex flex-col gap-3 p-4 w-96">
<div class="text-sm font-semibold text-ink">{column() ? "Edit calculation" : "New calculation"}</div>
<div>
<FormLabel>Column Name</FormLabel>
<FormInput small={true} class="w-full" placeholder="e.g. Total"
value={name()}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setName(e.currentTarget.value)} />
</div>
<div>
<FormulaField
mode="column"
onchange={setSpec}
operandOptions={operandOptions}
onHighlight={highlight}
apiRef={(api: FormulaFieldApi) => { formulaApi = api; }} />
<Show when={formulaError()}>
<p class="text-xs text-red-600 dark:text-red-400 mt-1">{formulaError()}</p>
</Show>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<FormLabel>Data type</FormLabel>
<FormSelect small={true} class="w-full"
value={dataType()}
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setDataType(e.currentTarget.value as CalculatedDataType)}>
<For each={CALC_DATATYPE_OPTIONS}>
{(d: { value: string; label: string }) => <option value={d.value}>{d.label}</option>}
</For>
</FormSelect>
</div>
<div>
<FormLabel>Decimals</FormLabel>
<FormInput small={true} class="w-full" placeholder="auto" inputMode="numeric"
value={decimals()}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setDecimals(e.currentTarget.value)} />
</div>
<div class="col-span-2">
<FormLabel>Alignment</FormLabel>
<FormSelect small={true} class="w-full"
value={position()}
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setPosition(e.currentTarget.value)}>
<For each={CALC_POSITION_OPTIONS}>
{(p: { value: string; label: string }) => <option value={p.value}>{p.label}</option>}
</For>
</FormSelect>
</div>
</div>
<Show when={errorMsg()}>
<p class="text-xs text-red-600 dark:text-red-400">{errorMsg()}</p>
</Show>
<div class="flex gap-2 items-center pt-1">
<Show when={!!column() && !!props.onRemove}>
<ButtonLinkRed onclick={(_e: MouseEvent) => handleRemove()}><span class="text-xs">Remove</span></ButtonLinkRed>
</Show>
<div class="flex gap-2 items-center ml-auto">
<ButtonUI small={true} color={BUTTON_COLOR_LIGHT_NEUTRAL} onclick={(_e: MouseEvent) => close()}>Cancel</ButtonUI>
<ButtonUI small={true} color={BUTTON_COLOR_PRIMARY} onclick={(_e: MouseEvent) => handleSave()}>Save</ButtonUI>
</div>
</div>
</div>;
}
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 <div class="flex flex-col gap-3 p-4 w-96">
<div class="text-sm font-semibold text-ink">{row() ? "Edit summary" : "New summary"}</div>
<div>
<FormLabel>Label</FormLabel>
<FormInput small={true} class="w-full" placeholder="e.g. Total"
value={label()}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setLabel(e.currentTarget.value)} />
</div>
<div>
<FormulaField
mode="summary"
onchange={setSpec}
operandOptions={operandOptions}
onHighlight={highlight}
apiRef={(api: FormulaFieldApi) => { formulaApi = api; }} />
<Show when={formulaError()}>
<p class="text-xs text-red-600 dark:text-red-400 mt-1">{formulaError()}</p>
</Show>
</div>
<div class="grid grid-cols-2 gap-2">
<div>
<FormLabel>Data type</FormLabel>
<FormSelect small={true} class="w-full"
value={dataType()}
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setDataType(e.currentTarget.value as CalculatedDataType)}>
<For each={CALC_DATATYPE_OPTIONS}>
{(d: { value: string; label: string }) => <option value={d.value}>{d.label}</option>}
</For>
</FormSelect>
</div>
<div>
<FormLabel>Decimals</FormLabel>
<FormInput small={true} class="w-full" placeholder="auto" inputMode="numeric"
value={decimals()}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setDecimals(e.currentTarget.value)} />
</div>
</div>
<Show when={errorMsg()}>
<p class="text-xs text-red-600 dark:text-red-400">{errorMsg()}</p>
</Show>
<div class="flex gap-2 items-center pt-1">
<Show when={!!row() && !!props.onRemove}>
<ButtonLinkRed onclick={(_e: MouseEvent) => handleRemove()}><span class="text-xs">Remove</span></ButtonLinkRed>
</Show>
<div class="flex gap-2 items-center ml-auto">
<ButtonUI small={true} color={BUTTON_COLOR_LIGHT_NEUTRAL} onclick={(_e: MouseEvent) => close()}>Cancel</ButtonUI>
<ButtonUI small={true} color={BUTTON_COLOR_PRIMARY} onclick={(_e: MouseEvent) => handleSave()}>Save</ButtonUI>
</div>
</div>
</div>;
}
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-surface-raised cursor-pointer border-0 bg-transparent flex items-start gap-2.5";
return <>
<Show when={view() === "menu"}>
<div class="flex flex-col gap-0.5 p-2 w-96">
<Show when={allowColumn()}>
<button type="button" class={ITEM} onclick={(_e: MouseEvent) => setView("column")}>
<Icon icon="table-columns" size={16} class="mt-0.5 text-ink-muted" />
<span class="flex flex-col">
<span class="text-sm font-medium text-ink">Calculated column</span>
<span class="text-xs text-ink-muted">A new column computed for each row</span>
</span>
</button>
</Show>
<Show when={allowSummary()}>
<button type="button" class={ITEM} onclick={(_e: MouseEvent) => setView("summary")}>
<Icon icon="list-ol" size={16} class="mt-0.5 text-ink-muted" />
<span class="flex flex-col">
<span class="text-sm font-medium text-ink">Summary row</span>
<span class="text-xs text-ink-muted">A total or aggregate shown in the footer</span>
</span>
</button>
</Show>
</div>
</Show>
<Show when={view() === "column"}>
<CalculatedColumnForm
column={() => null}
operandOptions={operandOptions}
onSave={props.onSaveColumn}
onHighlight={props.onHighlight} />
</Show>
<Show when={view() === "summary"}>
<SummaryRowForm
row={() => null}
operandOptions={operandOptions}
onSave={props.onSaveSummary}
onHighlight={props.onHighlight} />
</Show>
</>;
}
/**
* 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<any>);
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<index>" for data columns, "_calc_<id>" for calc columns).
const loadColumnWidths = (): Record<string, number> => {
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<string, number> = { ...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<string, UserCalculatedColumn>();
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_<id>".
const calcNameToRef = createMemo(() => {
const m = new Map<string, string>();
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<string, FormulaNode | null>();
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<string>) => {
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<string>): 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<string>): 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_<id>") 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_<id>").
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_<id>"). 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<index>", calc columns use their
// sortIdentifier ("_calc_<id>"). 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<string, { column: AutoTableColumn; originalIndex: number }>();
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<typeof setTimeout> | null = null;
let debounceTimerRef: ReturnType<typeof setTimeout> | 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 <td class={POS_CLS[pos]}>{formatCalculatedValue(item, col.calculated!)}</td>;
};
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<void> => {
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() ? (
<div class={toolbarActionsCls()}>
{props.toolbarActions && props.toolbarActions({ allFilteredData })}
<Show when={opts().calculatedColumns || opts().summaryRows}>
<Popover placement="bottom-end">
<PopoverTrigger class={CALC_ADD_TRIGGER_CLS}>
<Icon icon="plus" size={16} style={{ display: "inline", marginRight: "4px" }} />
Add Calculation
</PopoverTrigger>
<PopoverContent>
<AddCalcMenu
allowColumn={opts().calculatedColumns}
allowSummary={opts().summaryRows}
operandOptions={calcOperandOptions}
onSaveColumn={saveCalcColumn}
onSaveSummary={saveSummaryRow}
onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])}
/>
</PopoverContent>
</Popover>
</Show>
<Show when={opts().toggleColumns}>
<FormMultiSelectTrigger
trigger={<ButtonUI small={true} icon={true} color={BUTTON_COLOR_LIGHT_NEUTRAL}>
<Icon icon="table-columns" size={16} style={{ display: "inline", marginRight: "4px" }} />
Columns
</ButtonUI>}
options={columnToggleOptions()}
value={visibleColumns()}
onchange={setVisibleColumns}
align="right"
minWidth={180}
showSelectAll={true}
searchable={true}
small={true}
/>
</Show>
<Show when={opts().exportCSV && opts().userCustomizeExport}>
<Popover placement="bottom-end">
<PopoverTrigger class={CALC_ADD_TRIGGER_CLS} title="Export options">
<Icon icon="cog" size={16} style={{ display: "inline", marginRight: "4px" }} />
Export Options
</PopoverTrigger>
<PopoverContent>
<div class="flex flex-col gap-3 p-4 w-72">
<div class="text-sm font-semibold text-ink">Export settings</div>
<div>
<FormLabel>File name</FormLabel>
<FormInput small={true} class="w-full" placeholder="export"
value={exportFilenameInput()}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setExportFilenameInput(e.currentTarget.value)} />
</div>
<div>
<FormLabel>PDF title</FormLabel>
<FormInput small={true} class="w-full" placeholder="(none)"
value={pdfTitle()}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setPdfTitle(e.currentTarget.value)} />
</div>
<div>
<FormLabel>PDF subtitle</FormLabel>
<FormInput small={true} class="w-full" placeholder="(none)"
value={pdfSubtitle()}
oninput={(e: InputEvent & { currentTarget: HTMLInputElement }) => setPdfSubtitle(e.currentTarget.value)} />
</div>
<div>
<FormLabel>Orientation</FormLabel>
<FormSelect small={true} class="w-full"
value={pdfOrientation()}
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => setPdfOrientation(e.currentTarget.value)}>
<option value={String(PDF_ORIENTATION_LANDSCAPE)}>Landscape</option>
<option value={String(PDF_ORIENTATION_PORTRAIT)}>Portrait</option>
</FormSelect>
</div>
<label class="flex items-center gap-2 text-sm text-ink cursor-pointer">
<input type="checkbox" checked={pdfShowDate()} onchange={(e: Event & { currentTarget: HTMLInputElement }) => setPdfShowDate(e.currentTarget.checked)} />
Show date
</label>
</div>
</PopoverContent>
</Popover>
</Show>
<Show when={showExportMenu()}>
<Menu placement="bottom-end">
<MenuTrigger>
<ButtonUI small={true} icon={true} color={BUTTON_COLOR_LIGHT_NEUTRAL} disabled={isExporting()}>
<Icon icon="download" size={16} style={{ display: "inline", marginRight: "4px" }} />
Export
</ButtonUI>
</MenuTrigger>
<MenuContent>
<Show when={opts().exportCSV || props.exportMenuItems}>
<MenuSection>Spreadsheet</MenuSection>
</Show>
<Show when={opts().exportCSV}>
<MenuItem
icon="download"
onclick={(_e: MouseEvent) => handleExportCSV()}
disabled={isExporting()}
>
{isExporting() ? "Exporting..." : "Export CSV"}
</MenuItem>
</Show>
<Show when={!!resolveExportMenuItems()}>
{resolveExportMenuItems()}
</Show>
<Show when={opts().exportCSV}>
<MenuSection>PDF</MenuSection>
<MenuItem
icon="download"
onclick={(_e: MouseEvent) => handleExportPDF()}
disabled={isExporting()}
>
{isExporting() ? "Exporting..." : "Export PDF"}
</MenuItem>
<MenuItem
icon="print"
onclick={(_e: MouseEvent) => handlePrintPDF()}
disabled={isExporting()}
>
{isExporting() ? "Loading..." : "Print PDF"}
</MenuItem>
</Show>
</MenuContent>
</Menu>
</Show>
</div>
) : 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 = <button
type="button"
class={FILTERS_TOGGLE}
onclick={toggleFilters}
aria-expanded={filtersOpen()}
>
<span class="flex items-center gap-2">
Filters
<Show when={activeFilterCount() > 0}>
<span class={FILTERS_BADGE}>{activeFilterCount()}</span>
</Show>
</span>
<Icon
icon="chevron-down"
size={16}
class={"shrink-0 transition-transform " + (filtersOpen() ? "rotate-180" : "")}
/>
</button>;
const toolbarPanelActions = () => showToolbar() ? (
<div class={panelActionsCls()}>
{toolbarButtons()}
</div>
) : null;
return <div class="min-w-0 w-full max-w-full">
<Show when={(props.searchFields && !opts().searchAside) || toolbarButtons()}>
<div class={TOOLBAR}>
<div class={TOOLBAR_MOBILE_BAR}>
<Show when={props.searchFields && !opts().searchAside}>
{filtersToggleBtn}
</Show>
</div>
<div class={desktopRowCls()}>
<Show when={props.searchFields && !opts().searchAside}>
<div class={inlineFiltersBodyCls()}>
{untrack(() => props.searchFields!(searchFieldsCtx))}
{toolbarPanelActions()}
</div>
</Show>
<Show when={!props.searchFields && showToolbar()}>
<div class="hidden lg:contents">
{toolbarButtons()}
</div>
</Show>
</div>
</div>
</Show>
<div class={opts().searchAside ? "flex flex-col gap-2 lg:flex-row lg:gap-4 lg:items-start" : ""}>
<Show when={opts().searchAside && props.searchFields}>
<aside class={SEARCH_CARD + " flex flex-col gap-3"}>
{filtersToggleBtn}
<div class={asideFiltersBodyCls()}>
{untrack(() => props.searchFields!(searchFieldsCtx))}
{toolbarPanelActions()}
</div>
</aside>
</Show>
<div class={opts().searchAside ? "flex-1 min-w-0" : ""}>
{props.aboveTable}
<div class={TBL_CONTAINER
+ (opts().surroundingBorder ? " border border-line-strong" : "")
+ (opts().shadow ? " shadow-sm" : "")}>
<div class={TBL_WRAPPER}>
<table
style={pinnedTableWidth() != null ? { width: pinnedTableWidth() + "px" } : undefined}
class={"border-collapse" + (pinnedTableWidth() != null ? "" : " " + TBL_BASE) + (opts().tableLayoutAuto ? "" : " table-fixed")}
>
<thead class="[&_th]:border-b [&_th]:border-line-strong">
<tr>
<Show when={opts().accordion}>
<th class={getHeaderPaddingClass() + " " + getHeaderColorClass() + " " + ACCORDION_TOGGLE_TH} />
</Show>
<Show when={renderedColumnCount() === 0}>
<th class={getHeaderPaddingClass() + " " + getHeaderColorClass()}>&nbsp;</th>
</Show>
<For each={displayColumnsWithIndices()}>
{({ 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 <th
data-colkey={colKey}
draggable={opts().draggableColumns && resizingColumn() === null}
onDragStart={(e) => 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);
}
}}
>
<Show when={opts().resizableColumns && !(appendedCalcColumns().length === 0 && displayIdx() === displayColumnsWithIndices().length - 1)}>
<div class={RESIZE_HANDLE_CLS}
onMouseDown={(e: MouseEvent) => startColumnResize(e, colKey)}
onclick={(e: MouseEvent) => e.stopPropagation()}></div>
</Show>
<div class={HEADER_CONTENT + (isDragging() ? " scale-95" : "")}>
<div class={HEADER_INNER_BASE + (headerInnerPosCls ? " " + headerInnerPosCls : "")}>
<Show when={opts().draggableColumns}>
<span class={DRAG_HANDLE + " " + getHeaderDragClass()}>
<Icon icon="grip-vertical" size={14} />
</span>
</Show>
<Show when={!!col.calculated}>
<span class="shrink-0" onclick={(e: MouseEvent) => e.stopPropagation()}>
<Popover placement="bottom-end">
<PopoverTrigger
class="opacity-50 hover:opacity-100 bg-transparent border-0 p-0 leading-none cursor-pointer"
title="Edit calculation">
<Icon icon="pen-to-square" size={13} />
</PopoverTrigger>
<PopoverContent>
<CalculatedColumnForm
column={() => calcById().get((col.sortIdentifier || "").slice(CALC_REF_PREFIX.length)) ?? null}
operandOptions={calcOperandOptions}
onSave={saveCalcColumn}
onRemove={removeCalcColumn}
onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])}
/>
</PopoverContent>
</Popover>
</span>
</Show>
<div class={"grow text-sm " + getHeaderTextClass()}>
{col.displayName}
</div>
<Show when={col.sortable}>
<div class={SORT_ICON_WRAP + " w-4 text-center " + getHeaderSortIconClass()}>
<Show when={filter().OrderBy.Identifier && filter().OrderBy.Identifier === getSortIdentifier(col, originalIdx)}>
<Show when={filter().OrderBy.Descending}
fallback={<Icon icon="caret-up" size={16} solid={true} />}>
<Icon icon="caret-down" size={16} solid={true} />
</Show>
</Show>
</div>
</Show>
</div>
</div>
</th>;
}}
</For>
<For each={calcMerged() ? [] : userCalcColumns()}>
{(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 <th
data-colkey={sortId}
style={columnWidthStyle(sortId)}
class={getHeaderPaddingClass() + " " + getHeaderColorClass() + " " + POS_CLS[pos]
+ (opts().resizableColumns ? " relative" : "")
+ (opts().headerBorderY ? " border-l border-l-neutral-300" : "")
+ (highlightedCols().includes(sortId) ? "outline-2 -outline-offset-2 outline-amber-400" : "")
+ (sortable() ? " cursor-pointer" : "")
+ (sortable() && !resizingColumn() ? " " + getHeaderSortHover() : "")}
onclick={() => { if (sortable()) handleSort(sortId); }}>
<Show when={opts().resizableColumns && !isLastColumn()}>
<div class={RESIZE_HANDLE_CLS}
onMouseDown={(e: MouseEvent) => startColumnResize(e, sortId)}
onclick={(e: MouseEvent) => e.stopPropagation()}></div>
</Show>
<div class={HEADER_CONTENT}>
<div class={HEADER_INNER_BASE + " " + HEADER_INNER_POS[pos]}>
<span class="shrink-0" onclick={(e: MouseEvent) => e.stopPropagation()}>
<Popover placement="bottom-end">
<PopoverTrigger
class="opacity-50 hover:opacity-100 bg-transparent border-0 p-0 leading-none cursor-pointer"
title="Edit calculation">
<Icon icon="pen-to-square" size={13} />
</PopoverTrigger>
<PopoverContent>
<CalculatedColumnForm
column={() => uc}
operandOptions={calcOperandOptions}
onSave={saveCalcColumn}
onRemove={removeCalcColumn}
onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])}
/>
</PopoverContent>
</Popover>
</span>
<div class={"grow text-sm " + getHeaderTextClass()}>
{uc.displayName}
</div>
<Show when={sortable()}>
<div class={SORT_ICON_WRAP + " w-4 text-center " + getHeaderSortIconClass()}>
<Show when={filter().OrderBy.Identifier === sortId}>
<Show when={filter().OrderBy.Descending}
fallback={<Icon icon="caret-up" size={16} solid={true} />}>
<Icon icon="caret-down" size={16} solid={true} />
</Show>
</Show>
</div>
</Show>
</div>
</div>
</th>;
}}
</For>
</tr>
</thead>
<tbody class={getBodyPaddingClass() + (opts().borderY ? " [&_td+td]:border-l [&_td+td]:border-line-strong" : "")}>
<Show when={isLoading()}>
<For each={Array.from({ length: 5 })}>
{(_, rowIdx) => <tr class={opts().alternate && rowIdx() % 2 === 1 ? "bg-surface-raised" : ""}>
<Show when={opts().accordion}>
<td></td>
</Show>
<For each={[...displayColumnsWithIndices().map(({ column }) => column), ...appendedCalcColumns()]}>
{() => <td>
<div
class={SKELETON}
style={{ width: (60 + Math.random() * 30) + "%" }}
></div>
</td>}
</For>
</tr>}
</For>
</Show>
<Show when={!isLoading() && error()}>
<tr>
<td colspan={totalColumnCount() || 1} class={ERROR_CELL}>
Error: {error()}
</td>
</tr>
</Show>
<Show when={!isLoading() && !error() && renderedColumnCount() === 0}>
<tr>
<td colspan={totalColumnCount() || 1} class={EMPTY_CELL}>
No columns selected.
</td>
</tr>
</Show>
<Show when={!isLoading() && !error() && renderedColumnCount() > 0 && displayData().length === 0}>
<tr>
<td colspan={totalColumnCount()}>
{(typeof props.emptyMessage === "function" ? props.emptyMessage() : props.emptyMessage) || "No entries found."}
</td>
</tr>
</Show>
<Show when={!isLoading() && !error() && renderedColumnCount() > 0 && displayData().length > 0}>
<For each={displayData()}>
{(item, rowIdx) => {
const position = () => (displayPagination().CurrentPage - 1) * displayPagination().MaxItemsPerPage + rowIdx() + 1;
// A footer row is appended after the data rows in the same
// <tbody> (see props.footer below), so the final data row
// isn't visually last when one is present — it still needs
// its border-b to separate it from the footer.
const isLastRow = () => rowIdx() === displayData().length - 1 && !props.footer;
const rowKey = () => item.id ?? rowIdx();
const isExpanded = () => opts().accordion && expandedRows().has(rowKey());
const isHighlighted = () => !!(props.highlightMatch && props.highlightMatch(item));
return [
<tr
class={(() => {
let c = "";
if (isHighlighted()) {
// Highlight overrides alternate + hover with !important.
c += "bg-amber-100 dark:bg-amber-950/50! [&>td:first-child]:shadow-[inset_3px_0_0_var(--color-amber-500)] ";
} else {
if (opts().alternate && rowIdx() % 2 === 1) c += "bg-surface-raised ";
c += getRowHoverClass() + " ";
}
if (isExpanded() || (opts().borderX && !isLastRow())) c += "border-b border-line-strong ";
if (opts().accordion) c += "cursor-pointer select-none ";
return c.trim();
})()}
onclick={opts().accordion ? () => toggleAccordion(rowKey()) : undefined}
>
<Show when={opts().accordion}>
<td class={ACCORDION_TOGGLE_TD}>
<span class={ACCORDION_ICON + (isExpanded() ? " rotate-90" : "")}>
<Icon icon="chevron-right" size={14} />
</span>
</td>
</Show>
<Show when={(opts().draggableColumns || opts().toggleColumns || opts().accordion || opts().resizableColumns || hasCalculatedColumns()) && props.cellRenderer}
fallback={props.rowRenderer ? props.rowRenderer(item, position(), rowIdx()) : null}
>
<For each={displayColumnsWithIndices()}>
{({ column: col, originalIndex: originalIdx }) => {
if (col.calculated) return renderCalculatedCell(col, item);
return props.cellRenderer!(item, col, originalIdx, position(), rowIdx());
}}
</For>
</Show>
<For each={appendedCalcColumns()}>
{(col: AutoTableColumn) => renderCalculatedCell(col, item)}
</For>
</tr>,
<Show when={isExpanded() && props.accordionRenderer}>
<tr class={opts().borderX && !isLastRow() ? "border-b border-line-strong" : ""}>
<td colspan={totalColumnCount()}>
{props.accordionRenderer!(item, item[accordionKey()])}
</td>
</tr>
</Show>
];
}}
</For>
<Show when={props.footer}>{props.footer}</Show>
</Show>
</tbody>
<Show when={opts().summaryRows && userSummaryRows().length > 0}>
<tfoot class={getBodyPaddingClass()
+ " bg-surface-muted text-ink"
+ " [&_tr:first-child_td]:border-t [&_tr:first-child_td]:border-line-strong [&_tr:not(:first-child)_td]:border-t [&_tr:not(:first-child)_td]:border-line"
+ (opts().borderY ? " [&_td+td]:border-l [&_td+td]:border-line-strong" : "")}>
<For each={userSummaryRows()}>
{(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 <tr
class={(draggedSummary() === sIdx() ? "opacity-50 " : "") + (dragOverSummary() === sIdx() && draggedSummary() !== sIdx() ? "bg-surface-raised" : "")}
onDragOver={(e: DragEvent) => handleSummaryDragOver(e, sIdx())}
onDragLeave={() => setDragOverSummary(null)}
onDrop={(e: DragEvent) => handleSummaryDrop(e, sIdx())}>
<Show when={opts().accordion}><td></td></Show>
<For each={cols()}>
{(_col: AutoTableColumn, idx: () => number) => {
const isLbl = () => idx() === labelIdx();
const isVal = () => idx() === valueIdx();
const isFirst = () => idx() === 0;
return <td class={isFirst() ? "text-left" : "text-right"}>
<Show when={isFirst() && userSummaryRows().length > 1}>
<button type="button" draggable={true}
class="inline-flex items-center cursor-grab active:cursor-grabbing text-ink-faint hover:text-ink-muted bg-transparent border-0 p-0 leading-none align-middle"
title="Drag to reorder"
onDragStart={(e: DragEvent) => handleSummaryDragStart(e, sIdx())}
onDragEnd={handleSummaryDragEnd}>
<Icon icon="grip-vertical" size={12} />
</button>
</Show>
<Show when={isLbl() || isVal()}>
<div class="flex items-center justify-end gap-2 whitespace-nowrap">
<Show when={isLbl()}>
<Popover placement="bottom-end">
<PopoverTrigger class="inline-flex items-center gap-1 text-ink hover:text-ink-soft cursor-pointer bg-transparent border-0 p-0 font-bold">
{s.label}
<Icon icon="pen-to-square" size={11} class="opacity-50" />
</PopoverTrigger>
<PopoverContent>
<SummaryRowForm
row={() => s}
operandOptions={calcOperandOptions}
onSave={saveSummaryRow}
onRemove={removeSummaryRow}
onHighlight={(keys: string[] | null) => setHighlightedCols(keys ?? [])} />
</PopoverContent>
</Popover>
</Show>
<Show when={isVal()}>
<span>{value()}</span>
</Show>
</div>
</Show>
</td>;
}}
</For>
</tr>;
}}
</For>
</tfoot>
</Show>
</table>
</div>
<Show when={!opts().hidePagination || canReset()}>
<div class={PAGINATION_BASE + " " + getPaginationPaddingClass()}>
<div class="flex items-center">
<Show when={canReset()}>
<div class="mr-4">
<Menu placement="top-start">
<MenuTrigger>
<ButtonUI small={true} icon={true} color={BUTTON_COLOR_LIGHT_NEUTRAL}>
<Icon icon="arrow-rotate-left" size={16} style={{ display: "inline", marginRight: "4px" }} />
Reset
</ButtonUI>
</MenuTrigger>
<MenuContent>
<Show when={opts().draggableColumns}>
<MenuItem icon="table-columns" onclick={(_e: MouseEvent) => resetColumnOrder()}>Reset column order</MenuItem>
</Show>
<Show when={opts().resizableColumns}>
<MenuItem icon="ruler-horizontal" onclick={(_e: MouseEvent) => resetColumnWidths()}>Reset column widths</MenuItem>
</Show>
<Show when={opts().calculatedColumns}>
<MenuItem icon="trash-can" onclick={(_e: MouseEvent) => resetCalcColumns()}>Reset calculations</MenuItem>
</Show>
<Show when={opts().summaryRows}>
<MenuItem icon="trash-can" onclick={(_e: MouseEvent) => resetSummaryRows()}>Reset summaries</MenuItem>
</Show>
<MenuDivider />
<MenuItem icon="arrows-rotate" onclick={(_e: MouseEvent) => resetAll()}>Reset all</MenuItem>
</MenuContent>
</Menu>
</div>
</Show>
<div class={PAGINATION_INFO}>
<Show when={!opts().hidePagination}>
<b class="leading-none">
<Icon icon="list-ol" size={16} />
</b>
<span class="ml-3">
{displayPagination().ViewRangeLower}-{displayPagination().ViewRangeUpper} of {displayPagination().TotalItems}
</span>
</Show>
</div>
</div>
<div class={PAGINATION_CONTROLS}>
<Show when={!opts().hidePagination}>
<div class={PAGINATION_LABEL}>
Items per page:
</div>
<FormSelect
small={true}
class="mr-5"
value={displayPagination().MaxItemsPerPage}
onchange={(e: Event & { currentTarget: HTMLSelectElement }) => handleItemsPerPageChange(parseInt(e.currentTarget.value))}
>
<option value="5">5</option>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
<Show when={opts().paginationShowAll}>
<option value="-1">All</option>
</Show>
</FormSelect>
<PaginationButton
onclick={() => handlePageChange(1)}
disabled={displayPagination().CurrentPage <= 1}
>
<Icon icon="angles-left" size={16} />
</PaginationButton>
<PaginationButton
onclick={() => handlePageChange(displayPagination().CurrentPage - 1)}
disabled={displayPagination().CurrentPage <= 1}
>
<Icon icon="chevron-left" size={16} />
</PaginationButton>
<div class={PAGINATION_PAGE}>
Page&nbsp;{displayPagination().CurrentPage} of {displayPagination().TotalPages}
</div>
<PaginationButton
onclick={() => handlePageChange(displayPagination().CurrentPage + 1)}
disabled={displayPagination().CurrentPage >= displayPagination().TotalPages}
>
<Icon icon="chevron-right" size={16} />
</PaginationButton>
<PaginationButton
onclick={() => handlePageChange(displayPagination().TotalPages)}
disabled={displayPagination().CurrentPage >= displayPagination().TotalPages}
>
<Icon icon="angles-right" size={16} />
</PaginationButton>
</Show>
</div>
</div>
</Show>
</div>
{props.belowTable}
</div>
</div>
</div>;
}
function PaginationButton(props: JSX.ButtonHTMLAttributes<HTMLButtonElement>) {
return <button
class={PAGINATION_BTN}
onclick={props.onclick}
disabled={props.disabled}
>
{props.children}
</button>;
}
export function TdLeft(props: JSX.TdHTMLAttributes<HTMLTableCellElement>) {
return <td class={"text-left " + (props.class || "")} style={props.style}>{props.children}</td>;
}
export function TdRight(props: JSX.TdHTMLAttributes<HTMLTableCellElement>) {
return <td class={"text-right " + (props.class || "")} style={props.style}>{props.children}</td>;
}
export function TdCenter(props: JSX.TdHTMLAttributes<HTMLTableCellElement>) {
return <td class={"text-center " + (props.class || "")} style={props.style}>{props.children}</td>;
}
export function TdUniformLeft(props: JSX.TdHTMLAttributes<HTMLTableCellElement>) {
return <td class={"text-left " + (props.class || "")} style={props.style}>{props.children}</td>;
}
export function TdUniformRight(props: JSX.TdHTMLAttributes<HTMLTableCellElement>) {
return <td class={"text-right " + (props.class || "")} style={props.style}>{props.children}</td>;
}
export function TdUniformCenter(props: JSX.TdHTMLAttributes<HTMLTableCellElement>) {
return <td class={"text-center " + (props.class || "")} style={props.style}>{props.children}</td>;
}
export default AutoTable;