move tsx kit -> uikit
This commit is contained in:
434
web/uikit/CellGrid.tsx
Normal file
434
web/uikit/CellGrid.tsx
Normal file
@@ -0,0 +1,434 @@
|
||||
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 (
|
||||
<th class={cls()} onclick={() => props.onSort?.(props.sortKey)}>
|
||||
<div class="flex items-center gap-0.5 min-w-0">
|
||||
<span class="truncate min-w-0 flex-1">{props.label}</span>
|
||||
<Show when={isActive()}>
|
||||
<span class="shrink-0"><Icon icon={descending() ? "caret-down" : "caret-up"} size={10}/></span>
|
||||
</Show>
|
||||
</div>
|
||||
</th>
|
||||
);
|
||||
}
|
||||
|
||||
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<unknown, DOMRect>;
|
||||
animateRows: (before: Map<unknown, DOMRect>) => 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<string, CellGridColumn>();
|
||||
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<unknown, HTMLElement>();
|
||||
const inputRefs = new Map<string, HTMLInputElement>();
|
||||
|
||||
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<string, CellGridColumn>();
|
||||
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<string, Set<unknown>> = {};
|
||||
if (!props.conflictFields) return result;
|
||||
for (const field of props.conflictFields) {
|
||||
const counts = new Map<unknown, number>();
|
||||
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<unknown>();
|
||||
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<unknown, DOMRect>) => {
|
||||
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<unknown, DOMRect>();
|
||||
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<unknown, DOMRect> => {
|
||||
const m = new Map<unknown, DOMRect>();
|
||||
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 (
|
||||
<SortableHeader label={col.label} sortKey={col.sortKey} width={col.width} minWidth={col.minWidth} current={getSortKey()} desc={getSortDesc()} onSort={handleSort}/>
|
||||
);
|
||||
}
|
||||
const cls = col.headerClass
|
||||
? GRID_HEADER_CLS + " " + col.headerClass + widthCls
|
||||
: GRID_HEADER_CLS + widthCls;
|
||||
return <th class={cls}>{col.label}</th>;
|
||||
};
|
||||
|
||||
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 <td class={cellCls()} onclick={col.onclick ? (_: MouseEvent) => col.onclick!(row) : undefined}>{col.render!(row)}</td>;
|
||||
}
|
||||
|
||||
if (col.readOnly) {
|
||||
const roCls = () => {
|
||||
if (typeof col.cellClass === "function") return col.cellClass(row) + sizeCls;
|
||||
return (col.cellClass || readonlyTdCls()) + sizeCls;
|
||||
};
|
||||
return <td class={roCls()} onclick={col.onclick ? (_: MouseEvent) => col.onclick!(row) : undefined}>{col.render ? col.render(row) : row[col.key]}</td>;
|
||||
}
|
||||
|
||||
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 (
|
||||
<td class={tdClass()}>
|
||||
<input ref={setInputRef(rowId, col.key)} class={inputCls} type="text" inputmode={col.inputMode || "text"} placeholder={col.placeholder || ""} value={row[col.key]} oninput={(e: InputEvent) => {
|
||||
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)}/>
|
||||
<Show when={props.conflictFields && props.conflictFields.includes(col.key) && hasConflict()}>
|
||||
<span class="pointer-events-none absolute right-0.5 top-1/2 -translate-y-1/2 text-amber-600" title="Duplicate value">
|
||||
<Icon icon="triangle-exclamation" size={12}/>
|
||||
</span>
|
||||
</Show>
|
||||
</td>
|
||||
);
|
||||
};
|
||||
|
||||
const tableCls = () => "min-w-full w-max border-collapse " + (dense() ? "text-xs" : "text-sm");
|
||||
|
||||
return (
|
||||
<div class="relative max-w-full overflow-x-auto border border-neutral-300 rounded-default bg-white tabular-nums">
|
||||
<table class={tableCls()}>
|
||||
<thead>
|
||||
<tr>
|
||||
<For each={props.columns}>{(col) => renderHeader(col)}</For>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody onKeyDown={handleKeyDown}>
|
||||
<For each={displayedRows()}>{(row) => (
|
||||
<tr ref={setRowRef(row[idField()])} class="odd:bg-white even:bg-neutral-100">
|
||||
<For each={props.columns}>{(col) => renderCell(row, col)}</For>
|
||||
</tr>
|
||||
)}</For>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user