import { createSignal, createMemo, untrack, Show, For, JSXElement } from "solid-js";
import { Icon } from "./Icons.tsx";
export const GRID_HEADER_CLS = "border-b border-r border-neutral-300 bg-neutral-50 px-1.5 py-1.5 text-left text-xs font-bold uppercase text-black whitespace-nowrap last:border-r-0";
interface SortableHeaderProps {
label: string;
sortKey: string;
width?: string;
minWidth?: string;
current: string | null;
desc: boolean;
onSort?: (key: string) => void;
}
function columnSizeClass(width?: string, minWidth?: string): string {
return width || minWidth || "";
}
export function SortableHeader(props: SortableHeaderProps) {
const isActive = () => {
const cur = typeof props.current === "function" ? (props.current as () => string | null)() : props.current;
return cur === props.sortKey;
};
const descending = () => {
const d = typeof props.desc === "function" ? (props.desc as () => boolean)() : props.desc;
return !!d;
};
const cls = () => GRID_HEADER_CLS + " cursor-pointer select-none hover:bg-neutral-200"
+ (columnSizeClass(props.width, props.minWidth) ? " " + columnSizeClass(props.width, props.minWidth) : "");
return (
props.onSort?.(props.sortKey)}>
{props.label}
|
);
}
export function compareRowsGeneric(a: any, b: any, key: string, sortType?: string): number {
const av = a[key];
const bv = b[key];
const aEmpty = av === "" || av == null;
const bEmpty = bv === "" || bv == null;
if (aEmpty && bEmpty) return 0;
if (aEmpty) return 1;
if (bEmpty) return -1;
if (sortType === "numeric") {
const as = String(av);
const bs = String(bv);
const am = /^(\d+)/.exec(as);
const bm = /^(\d+)/.exec(bs);
const an = am ? parseInt(am[1], 10) : NaN;
const bn = bm ? parseInt(bm[1], 10) : NaN;
if (!isNaN(an) && !isNaN(bn)) {
if (an !== bn) return an - bn;
return as.localeCompare(bs);
}
if (!isNaN(an)) return -1;
if (!isNaN(bn)) return 1;
return as.localeCompare(bs);
}
if (sortType === "money") {
return parseFloat(av) - parseFloat(bv);
}
return String(av).localeCompare(String(bv));
}
export interface CellGridColumn {
key: string;
label: string;
sortKey?: string;
sortType?: string;
sortValue?: (row: any) => unknown;
width?: string;
minWidth?: string;
headerClass?: string;
editable?: boolean;
readOnly?: boolean;
render?: (row: any) => JSXElement;
cellClass?: string | ((row: any) => string);
onclick?: (row: any) => void;
inputMode?: "decimal" | "email" | "none" | "numeric" | "search" | "tel" | "text" | "url" | undefined;
placeholder?: string;
parse?: (value: string) => unknown;
}
export interface CellGridApi {
dirty: () => boolean;
selected: () => { id: unknown; field: string } | null;
focusCell: (id: unknown, field: string) => void;
displayedRows: () => any[];
snapshotRowPositions: () => Map;
animateRows: (before: Map) => void;
}
interface CellGridProps {
columns: CellGridColumn[];
rows: any[];
initialRows: any[];
idField?: string;
onCellChange: (rowId: unknown, field: string, value: unknown) => void;
sortKey: string | null;
setSortKey: (key: string) => void;
sortDesc: boolean;
setSortDesc: (desc: boolean) => void;
conflictFields?: string[];
dense?: boolean;
ref?: (api: CellGridApi) => void;
}
export function CellGrid(props: CellGridProps) {
const getSortKey = () => typeof props.sortKey === "function" ? (props.sortKey as () => string | null)() : props.sortKey;
const getSortDesc = () => typeof props.sortDesc === "function" ? (props.sortDesc as () => boolean)() : props.sortDesc;
const idField = () => props.idField || "id";
const editableFields = createMemo(() => props.columns.filter((c) => c.editable).map((c) => c.key));
const columnsByKey = createMemo(() => {
const m = new Map();
for (const col of props.columns) m.set(col.key, col);
return m;
});
const [selected, setSelected] = createSignal<{ id: unknown; field: string } | null>(null);
const [sortStamp, setSortStamp] = createSignal(0);
const [blurStamp, setBlurStamp] = createSignal(0);
const rowRefs = new Map();
const inputRefs = new Map();
const setRowRef = (id: unknown) => (el: HTMLElement) => {
if (el) rowRefs.set(id, el);
};
const setInputRef = (id: unknown, field: string) => (el: HTMLInputElement) => {
const key = id + "::" + field;
if (el) inputRefs.set(key, el);
else inputRefs.delete(key);
};
const sortColMap = createMemo(() => {
const m = new Map();
for (const col of props.columns) {
if (col.sortKey) m.set(col.sortKey, col);
}
return m;
});
const sortedOrder = createMemo(() => {
sortStamp();
props.initialRows;
props.rows.length;
const sk = getSortKey();
const desc = getSortDesc();
const col = sortColMap().get(sk || "");
const st = col?.sortType || "string";
const getVal = typeof col?.sortValue === "function" ? col.sortValue : (r: any) => r[sk || ""];
return untrack(() => {
const idf = idField();
const snap = props.rows.map((r) => ({ id: r[idf], sortVal: getVal(r) }));
snap.sort((a, b) => compareRowsGeneric(a, b, "sortVal", st));
if (desc) snap.reverse();
return snap.map((s) => s.id);
});
});
const displayedRows = createMemo(() => {
const order = sortedOrder();
const idf = idField();
const byId = new Map();
for (let i = 0; i < props.rows.length; i++) {
byId.set(props.rows[i][idf], props.rows[i]);
}
const out: any[] = [];
for (const id of order) {
const r = byId.get(id);
if (r) out.push(r);
}
return out;
});
const dirty = createMemo(() => {
const current = props.rows;
const initial = props.initialRows;
if (!initial || current.length !== initial.length) return true;
const fields = editableFields();
for (let i = 0; i < current.length; i++) {
for (const f of fields) {
if (current[i][f] !== initial[i][f]) return true;
}
}
return false;
});
const conflictSets = createMemo(() => {
blurStamp();
props.initialRows;
return untrack(() => {
const result: Record> = {};
if (!props.conflictFields) return result;
for (const field of props.conflictFields) {
const counts = new Map();
for (let i = 0; i < props.rows.length; i++) {
const v = props.rows[i][field];
if (!v) continue;
counts.set(v, (counts.get(v) || 0) + 1);
}
const conflicts = new Set();
counts.forEach((c, v) => { if (c > 1) conflicts.add(v); });
result[field] = conflicts;
}
return result;
});
});
const isConflict = (field: string, value: unknown): boolean => {
if (!value) return false;
const sets = conflictSets();
return !!sets[field] && sets[field].has(value);
};
const animateReorder = (prevPositions: Map) => {
rowRefs.forEach((el, id) => {
const prev = prevPositions.get(id);
if (!prev || !el.isConnected) return;
const next = el.getBoundingClientRect();
const dy = prev.top - next.top;
if (dy === 0) return;
el.animate(
[{ transform: `translateY(${dy}px)` }, { transform: "translateY(0)" }],
{ duration: 300, easing: "cubic-bezier(0.22, 0.61, 0.36, 1)" }
);
});
};
const handleSort = (key: string) => {
const positions = new Map();
rowRefs.forEach((el, id) => {
if (el.isConnected) positions.set(id, el.getBoundingClientRect());
});
if (getSortKey() === key) {
props.setSortDesc(!getSortDesc());
} else {
props.setSortKey(key);
props.setSortDesc(false);
}
setSortStamp((s) => s + 1);
animateReorder(positions);
};
const focusCell = (id: unknown, field: string) => {
const el = inputRefs.get(id + "::" + field);
if (el) {
el.focus();
try { el.select(); } catch {}
}
};
const moveSelection = (dCol: number, dRow: number) => {
const cur = selected();
const rows = displayedRows();
const fields = editableFields();
if (rows.length === 0 || fields.length === 0) return;
const idf = idField();
let rowIdx = cur ? rows.findIndex((r) => r[idf] === cur.id) : 0;
let colIdx = cur ? fields.indexOf(cur.field) : 0;
if (rowIdx < 0) rowIdx = 0;
if (colIdx < 0) colIdx = 0;
const newRowIdx = Math.max(0, Math.min(rows.length - 1, rowIdx + dRow));
const newColIdx = Math.max(0, Math.min(fields.length - 1, colIdx + dCol));
const newId = rows[newRowIdx][idf];
const newField = fields[newColIdx];
setSelected({ id: newId, field: newField });
focusCell(newId, newField);
};
const shouldNavigateHorizontal = (input: HTMLInputElement | null): boolean => {
if (!input) return false;
if (!input.value) return true;
return typeof input.selectionStart === "number" && input.selectionStart !== input.selectionEnd;
};
const handleKeyDown = (e: KeyboardEvent) => {
const key = e.key;
const input = e.target as HTMLInputElement;
if (key === "Enter") {
e.preventDefault();
moveSelection(0, e.shiftKey ? -1 : 1);
return;
}
if (key === "Tab") {
e.preventDefault();
moveSelection(e.shiftKey ? -1 : 1, 0);
return;
}
if (key === "Escape") {
if (input && typeof input.setSelectionRange === "function") {
const pos = input.selectionEnd || 0;
try { input.setSelectionRange(pos, pos); } catch {}
}
e.preventDefault();
return;
}
if (key === "ArrowUp" || key === "ArrowDown") {
e.preventDefault();
moveSelection(0, key === "ArrowDown" ? 1 : -1);
return;
}
if (key === "ArrowLeft" || key === "ArrowRight") {
if (shouldNavigateHorizontal(input)) {
e.preventDefault();
moveSelection(key === "ArrowRight" ? 1 : -1, 0);
}
return;
}
};
const handleCellFocus = (id: unknown, field: string) => {
setSelected({ id, field });
};
const handleCellMouseDown = (id: unknown, field: string) => {
setSelected({ id, field });
};
const snapshotRowPositions = (): Map => {
const m = new Map();
rowRefs.forEach((el, id) => {
if (el.isConnected) m.set(id, el.getBoundingClientRect());
});
return m;
};
props.ref?.({
dirty,
selected,
focusCell,
displayedRows,
snapshotRowPositions,
animateRows: animateReorder,
});
const dense = () => !!props.dense;
const rowHCls = () => dense() ? "h-6" : "h-8";
const readonlyTdCls = () => "border-b border-r border-neutral-300 bg-black/5 px-2 text-neutral-700 align-middle " + rowHCls();
const editableTdCls = () => "border-b border-r border-neutral-300 p-0 relative align-middle";
const inputCls = "absolute inset-0 w-full border-none outline-none bg-transparent px-2 placeholder:text-neutral-400 focus:bg-red-50 focus:shadow-[inset_0_0_0_2px_var(--color-red-500)]";
const colSizeCls = (col: CellGridColumn) => columnSizeClass(col.width, col.minWidth);
const renderHeader = (col: CellGridColumn) => {
const widthCls = colSizeCls(col) ? " " + colSizeCls(col) : "";
if (col.sortKey) {
return (
);
}
const cls = col.headerClass
? GRID_HEADER_CLS + " " + col.headerClass + widthCls
: GRID_HEADER_CLS + widthCls;
return {col.label} | ;
};
const renderCell = (row: any, col: CellGridColumn) => {
const idf = idField();
const rowId = row[idf];
const sizeCls = colSizeCls(col) ? " " + colSizeCls(col) : "";
if (col.render && !col.editable) {
const cellCls = () => {
if (typeof col.cellClass === "function") return col.cellClass(row) + sizeCls;
return (col.cellClass || readonlyTdCls()) + sizeCls;
};
return col.onclick!(row) : undefined}>{col.render!(row)} | ;
}
if (col.readOnly) {
const roCls = () => {
if (typeof col.cellClass === "function") return col.cellClass(row) + sizeCls;
return (col.cellClass || readonlyTdCls()) + sizeCls;
};
return col.onclick!(row) : undefined}>{col.render ? col.render(row) : row[col.key]} | ;
}
const hasConflict = () => isConflict(col.key, row[col.key]);
const tdClass = () => {
let base = editableTdCls() + sizeCls;
if (props.conflictFields && props.conflictFields.includes(col.key)) {
base += " relative";
if (hasConflict()) base += " bg-amber-100";
}
return base;
};
return (
{
const target = e.currentTarget as HTMLInputElement;
const val = col.parse ? col.parse(target.value) : target.value;
props.onCellChange(rowId, col.key, val);
}} onFocus={() => handleCellFocus(rowId, col.key)} onBlur={() => setBlurStamp((s) => s + 1)} onMouseDown={() => handleCellMouseDown(rowId, col.key)}/>
|
);
};
const tableCls = () => "min-w-full w-max border-collapse " + (dense() ? "text-xs" : "text-sm");
return (
{(col) => renderHeader(col)}
{(row) => (
{(col) => renderCell(row, col)}
)}
);
}