chore(repo): initialize planarchy workspace

This commit is contained in:
2026-03-14 14:31:09 +01:00
commit dd55d0e78b
769 changed files with 166461 additions and 0 deletions
+98
View File
@@ -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 };
}