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)} )}
); }