chore(repo): initialize planarchy workspace
This commit is contained in:
@@ -0,0 +1,98 @@
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user