93 lines
3 KiB
TypeScript
93 lines
3 KiB
TypeScript
|
|
"use client";
|
||
|
|
|
||
|
|
import { useCallback, useState } from "react";
|
||
|
|
|
||
|
|
/**
|
||
|
|
* localStorage-backed persistence for a boolean UI preference.
|
||
|
|
*
|
||
|
|
* Mirror of `useSplitRatio` for boolean-shaped state. Used by the
|
||
|
|
* invoicing page's "Detailed" toggle so each operator's preferred
|
||
|
|
* row density sticks across reloads.
|
||
|
|
*
|
||
|
|
* SSR-safe: `localStorage` is gated on `typeof window`. The hook
|
||
|
|
* reads the stored value lazily on first render and ignores writes
|
||
|
|
* during SSR so server-rendered HTML matches the client's initial
|
||
|
|
* paint (avoids hydration warnings).
|
||
|
|
*
|
||
|
|
* Read semantics: a stored value of `"true"` parses to `true`;
|
||
|
|
* anything else (including `"false"`, an unparseable string, or a
|
||
|
|
* throwing `localStorage`) is treated as "nothing stored" and the
|
||
|
|
* caller's `initial` wins. This is the conservative pick — if a
|
||
|
|
* future migration changes the storage format, a stale string falls
|
||
|
|
* back to the default rather than mis-interpreted as `false`.
|
||
|
|
*/
|
||
|
|
const isBrowser = typeof window !== "undefined";
|
||
|
|
|
||
|
|
function readStored(key: string): boolean | null {
|
||
|
|
if (!isBrowser) return null;
|
||
|
|
try {
|
||
|
|
const raw = window.localStorage.getItem(key);
|
||
|
|
if (raw === null) return null;
|
||
|
|
if (raw === "true") return true;
|
||
|
|
if (raw === "false") return false;
|
||
|
|
return null;
|
||
|
|
} catch {
|
||
|
|
// Safari private mode / quota errors / SecurityError — treat as no value.
|
||
|
|
return null;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function writeStored(key: string, value: boolean): void {
|
||
|
|
if (!isBrowser) return;
|
||
|
|
try {
|
||
|
|
window.localStorage.setItem(key, value ? "true" : "false");
|
||
|
|
} catch {
|
||
|
|
// Best-effort persistence. A failed write must not break the UI —
|
||
|
|
// the in-memory state is the source of truth for the current session.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
export type UseBooleanPreferenceOptions = {
|
||
|
|
/** localStorage key. Namespace it per-feature to avoid collisions. */
|
||
|
|
storageKey: string;
|
||
|
|
/** Value to use when nothing is stored. */
|
||
|
|
initial: boolean;
|
||
|
|
};
|
||
|
|
|
||
|
|
export type UseBooleanPreferenceResult = {
|
||
|
|
value: boolean;
|
||
|
|
setValue: (next: boolean) => void;
|
||
|
|
};
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Persistent boolean preference hook.
|
||
|
|
*
|
||
|
|
* Returns the current value and a setter. The setter persists the
|
||
|
|
* new value synchronously, so flipping the toggle and reloading the
|
||
|
|
* page lands on the chosen state on the very first render (no
|
||
|
|
* flicker — `useState`'s initialiser reads localStorage).
|
||
|
|
*/
|
||
|
|
export function useBooleanPreference({
|
||
|
|
storageKey,
|
||
|
|
initial,
|
||
|
|
}: UseBooleanPreferenceOptions): UseBooleanPreferenceResult {
|
||
|
|
// Lazy init from localStorage so the very first render uses the
|
||
|
|
// stored value when present. We don't need a follow-up `useEffect`
|
||
|
|
// to re-read on mount — `useState`'s initialiser already runs once
|
||
|
|
// per mount with the current localStorage value.
|
||
|
|
const [value, setValueState] = useState<boolean>(() => {
|
||
|
|
const stored = readStored(storageKey);
|
||
|
|
return stored ?? initial;
|
||
|
|
});
|
||
|
|
|
||
|
|
const setValue = useCallback(
|
||
|
|
(next: boolean) => {
|
||
|
|
setValueState(next);
|
||
|
|
writeStored(storageKey, next);
|
||
|
|
},
|
||
|
|
[storageKey],
|
||
|
|
);
|
||
|
|
|
||
|
|
return { value, setValue };
|
||
|
|
}
|