146 lines
5.9 KiB
TypeScript
146 lines
5.9 KiB
TypeScript
"use client";
|
|
|
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
|
|
// --- "Confirm before it's gone" hold ---------------------------------------
|
|
//
|
|
// Some lists drop an item from view the instant some action makes it no
|
|
// longer "belong" there: checking off a compact-view to-do or an Overdue
|
|
// occurrence, or -- with nothing to check off yet -- a brand-new empty
|
|
// group in compact view, which would otherwise never even flash on screen.
|
|
// That underlying change still applies instantly; the item's row just
|
|
// sticks around a while longer before actually leaving the list, long
|
|
// enough to catch an accidental tap or give a freshly-created group a
|
|
// moment to be noticed. Three timed stages, each its own setTimeout so a
|
|
// mid-flight cancel (an uncheck, or the group being given real content)
|
|
// can stop it cleanly:
|
|
// "visible" (0s-holdMs) full opacity, still on screen.
|
|
// "fading" (+0s-1s) opacity eases to 0 over 1s.
|
|
// "collapsing" (+1s-1.2s) height eases to 0 so the row (and
|
|
// anything that only existed to contain
|
|
// it) contracts around it, then it's gone.
|
|
// This is presentation-only bookkeeping -- whatever underlying state a
|
|
// caller is reacting to (a to-do's `completed` flag, a group merely
|
|
// existing) changes immediately regardless; this only delays the point at
|
|
// which a list's own filtering stops counting the item as "still here".
|
|
// Ids are opaque strings, so unrelated features (a to-do id, a group id, a
|
|
// scheduled occurrence's `${scheduledTodoId}-${occurrenceDate}` key) can
|
|
// safely share one instance without knowing about each other.
|
|
//
|
|
// A hold can also be frozen mid-flight (pauseHold) and later resumed
|
|
// (beginHold) or dropped (cancelHold) -- e.g. to keep a brand-new group on
|
|
// screen for as long as its "add to-do" window is still open, however long
|
|
// that takes.
|
|
const HOLD_MS = 6_000;
|
|
const FADE_MS = 1_000;
|
|
const COLLAPSE_MS = 200;
|
|
// The one-off grace period a brand-new (necessarily empty) group gets in
|
|
// compact view -- without it, compact's "hide empty groups" rule would drop
|
|
// it before it was ever seen, so this is its "moment to be noticed / have a
|
|
// to-do added" window. Longer than the usual hold since there's no
|
|
// accidental click to forgive here.
|
|
export const NEW_GROUP_HOLD_MS = 15_000;
|
|
|
|
export type HoldPhase = "visible" | "fading" | "collapsing";
|
|
|
|
interface HoldOnCompleteContextValue {
|
|
holds: Map<string, HoldPhase>;
|
|
// holdMs defaults to the standard 6s grace period; pass a longer one
|
|
// (e.g. giving a freshly-created group more time to be noticed) to
|
|
// override just the "visible" stage's length.
|
|
beginHold: (id: string, holdMs?: number) => void;
|
|
cancelHold: (id: string) => void;
|
|
// Freeze a live hold in place: clears its pending stage timers and
|
|
// (re)sets it to full "visible" with nothing scheduled after, so the item
|
|
// stays on screen until beginHold (resume the staged fade-out) or
|
|
// cancelHold (drop it) is called for it again. No-op if the id has no
|
|
// live hold.
|
|
pauseHold: (id: string) => void;
|
|
}
|
|
|
|
const HoldOnCompleteContext = createContext<HoldOnCompleteContextValue | null>(null);
|
|
|
|
export function HoldOnCompleteProvider({ children }: { children: React.ReactNode }) {
|
|
const [holds, setHolds] = useState<Map<string, HoldPhase>>(new Map());
|
|
// Per-id pending timeouts, so a cancel (or a fresh re-check restarting
|
|
// the clock) can clear exactly its own stage timers and no one else's.
|
|
const timers = useRef(new Map<string, ReturnType<typeof setTimeout>[]>());
|
|
|
|
const clearTimers = useCallback((id: string) => {
|
|
for (const timer of timers.current.get(id) ?? []) clearTimeout(timer);
|
|
timers.current.delete(id);
|
|
}, []);
|
|
|
|
const setPhase = useCallback((id: string, phase: HoldPhase | null) => {
|
|
setHolds((prev) => {
|
|
const next = new Map(prev);
|
|
if (phase === null) next.delete(id);
|
|
else next.set(id, phase);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
const beginHold = useCallback(
|
|
(id: string, holdMs: number = HOLD_MS) => {
|
|
clearTimers(id);
|
|
setPhase(id, "visible");
|
|
timers.current.set(id, [
|
|
setTimeout(() => setPhase(id, "fading"), holdMs),
|
|
setTimeout(() => setPhase(id, "collapsing"), holdMs + FADE_MS),
|
|
setTimeout(() => setPhase(id, null), holdMs + FADE_MS + COLLAPSE_MS),
|
|
]);
|
|
},
|
|
[clearTimers, setPhase]
|
|
);
|
|
|
|
// Called both to catch an accidental-check undo mid-hold and, harmlessly,
|
|
// on every ordinary uncheck of an already-settled item.
|
|
const cancelHold = useCallback(
|
|
(id: string) => {
|
|
clearTimers(id);
|
|
setPhase(id, null);
|
|
},
|
|
[clearTimers, setPhase]
|
|
);
|
|
|
|
// Freeze an existing hold: the item snaps back (and stays) at full
|
|
// "visible" opacity with no stage timers pending, so it lingers on screen
|
|
// indefinitely -- call beginHold to resume the staged fade-out, or
|
|
// cancelHold to drop the hold outright.
|
|
const pauseHold = useCallback(
|
|
(id: string) => {
|
|
clearTimers(id);
|
|
setHolds((prev) => {
|
|
// Pausing is only meaningful for an id that's already mid-hold --
|
|
// don't invent a hold for one that isn't.
|
|
if (!prev.has(id)) return prev;
|
|
const next = new Map(prev);
|
|
next.set(id, "visible");
|
|
return next;
|
|
});
|
|
},
|
|
[clearTimers]
|
|
);
|
|
|
|
// Belt-and-suspenders: drop any still-pending timers on unmount so they
|
|
// don't fire setState against a gone provider.
|
|
useEffect(() => {
|
|
const pending = timers.current;
|
|
return () => {
|
|
for (const list of pending.values()) list.forEach(clearTimeout);
|
|
};
|
|
}, []);
|
|
|
|
return (
|
|
<HoldOnCompleteContext.Provider value={{ holds, beginHold, cancelHold, pauseHold }}>
|
|
{children}
|
|
</HoldOnCompleteContext.Provider>
|
|
);
|
|
}
|
|
|
|
export function useHoldOnComplete() {
|
|
const ctx = useContext(HoldOnCompleteContext);
|
|
if (!ctx) throw new Error("useHoldOnComplete must be used within a HoldOnCompleteProvider");
|
|
return ctx;
|
|
}
|