99 lines
3.1 KiB
TypeScript
99 lines
3.1 KiB
TypeScript
import { useState, useMemo, useEffect, useRef } from "react";
|
|
|
|
export type SortDir = "asc" | "desc" | null;
|
|
|
|
export interface TableSortState<F extends string> {
|
|
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<T extends object, F extends string = string>(
|
|
rows: T[],
|
|
options?: UseTableSortOptions,
|
|
) {
|
|
const [sortField, setSortField] = useState<F | null>(
|
|
(options?.initialField ?? null) as F | null,
|
|
);
|
|
const [sortDir, setSortDir] = useState<SortDir>(options?.initialDir ?? null);
|
|
// Store custom getValue functions keyed by field
|
|
const [getters, setGetters] = useState<Partial<Record<F, (row: T) => 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 };
|
|
}
|