import { useState, useMemo, useEffect, useRef } from "react"; export type SortDir = "asc" | "desc" | null; export interface TableSortState { sorted: never[]; // overridden per call sortField: F | null; sortDir: SortDir; toggle: (field: F, getValue?: (row: never) => string | number | null | undefined) => void; reset: () => void; } export interface UseTableSortOptions { /** Initial sort field loaded from persisted preferences. */ initialField?: string | null; /** Initial sort direction loaded from persisted preferences. */ initialDir?: "asc" | "desc" | null; /** * Called whenever the sort state changes (after the first render). * Use this to persist the new sort state. */ onSortChange?: (field: string | null, dir: SortDir) => void; } function compareValues(a: unknown, b: unknown, dir: "asc" | "desc"): number { // Nulls last regardless of direction if (a == null && b == null) return 0; if (a == null) return 1; if (b == null) return -1; let result: number; if (typeof a === "string" && typeof b === "string") { result = a.localeCompare(b, undefined, { numeric: true, sensitivity: "base" }); } else if (typeof a === "number" && typeof b === "number") { result = a - b; } else { result = String(a).localeCompare(String(b)); } return dir === "asc" ? result : -result; } export function useTableSort( rows: T[], options?: UseTableSortOptions, ) { const [sortField, setSortField] = useState( (options?.initialField ?? null) as F | null, ); const [sortDir, setSortDir] = useState(options?.initialDir ?? null); // Store custom getValue functions keyed by field const [getters, setGetters] = useState unknown>>>({}); // Keep onSortChange in a ref so the effect doesn't need it as a dependency const onSortChangeRef = useRef(options?.onSortChange); onSortChangeRef.current = options?.onSortChange; // Skip the initial render — only fire onSortChange for user-driven changes const isFirstRender = useRef(true); useEffect(() => { if (isFirstRender.current) { isFirstRender.current = false; return; } onSortChangeRef.current?.(sortField, sortDir); }, [sortField, sortDir]); function toggle(field: F, getValue?: (row: T) => unknown) { if (getValue) { setGetters((prev) => ({ ...prev, [field]: getValue })); } setSortField((prev) => { if (prev !== field) { setSortDir("asc"); return field; } // Cycle: asc → desc → null setSortDir((d) => { if (d === "asc") return "desc"; if (d === "desc") return null; return "asc"; }); return prev; }); } function reset() { setSortField(null); setSortDir(null); } const sorted = useMemo((): T[] => { if (!sortField || !sortDir) return rows; const getter = getters[sortField] ?? ((row: T) => row[sortField as unknown as keyof T]); return [...rows].sort((a, b) => compareValues(getter(a), getter(b), sortDir)); }, [rows, sortField, sortDir, getters]); return { sorted, sortField, sortDir, toggle, reset }; }