99 lines
2.9 KiB
TypeScript
99 lines
2.9 KiB
TypeScript
"use client";
|
|
import { useCallback, useEffect, useRef } from "react";
|
|
|
|
interface Guard {
|
|
dirty: boolean;
|
|
message: string;
|
|
}
|
|
const guards = new Set<{ current: Guard }>();
|
|
/** Call before programmatic navigation that would replace an unsaved form. */
|
|
export function confirmUnsavedNavigation(): boolean {
|
|
const guard = [...guards].find((entry) => entry.current.dirty)?.current;
|
|
return !guard || window.confirm(guard.message);
|
|
}
|
|
function leavesCurrentPage(href: string) {
|
|
const target = new URL(href, location.href);
|
|
return (
|
|
(target.protocol === "http:" || target.protocol === "https:") &&
|
|
(target.origin !== location.origin ||
|
|
target.pathname !== location.pathname ||
|
|
target.search !== location.search)
|
|
);
|
|
}
|
|
function beforeUnload(event: BeforeUnloadEvent) {
|
|
if (![...guards].some((entry) => entry.current.dirty)) return;
|
|
event.preventDefault();
|
|
event.returnValue = "";
|
|
}
|
|
function click(event: MouseEvent) {
|
|
if (
|
|
event.defaultPrevented ||
|
|
event.button !== 0 ||
|
|
event.ctrlKey ||
|
|
event.metaKey ||
|
|
event.shiftKey ||
|
|
event.altKey
|
|
)
|
|
return;
|
|
const link =
|
|
event.target instanceof Element ? event.target.closest("a[href]") : null;
|
|
if (
|
|
!(link instanceof HTMLAnchorElement) ||
|
|
link.hasAttribute("download") ||
|
|
(link.target && link.target !== "_self") ||
|
|
!leavesCurrentPage(link.href)
|
|
)
|
|
return;
|
|
if (!confirmUnsavedNavigation()) {
|
|
event.preventDefault();
|
|
event.stopImmediatePropagation();
|
|
}
|
|
}
|
|
interface HistoryNavigationEvent extends Event {
|
|
navigationType: string;
|
|
destination: { url: string };
|
|
}
|
|
function traverse(event: Event) {
|
|
const navigation = event as HistoryNavigationEvent;
|
|
if (
|
|
navigation.navigationType !== "traverse" ||
|
|
!event.cancelable ||
|
|
!leavesCurrentPage(navigation.destination.url)
|
|
)
|
|
return;
|
|
if (!confirmUnsavedNavigation()) event.preventDefault();
|
|
}
|
|
/** Protect reload, links and supported browser history traversals; expose explicit close protection. */
|
|
export function useUnsavedChanges(dirty: boolean, message: string) {
|
|
const guard = useRef({ dirty, message });
|
|
guard.current = { dirty, message };
|
|
useEffect(() => {
|
|
if (!dirty) return;
|
|
const entry = guard;
|
|
const navigation = (window as Window & { navigation?: EventTarget })
|
|
.navigation;
|
|
if (guards.size === 0) {
|
|
window.addEventListener("beforeunload", beforeUnload);
|
|
document.addEventListener("click", click, true);
|
|
navigation?.addEventListener("navigate", traverse);
|
|
}
|
|
guards.add(entry);
|
|
return () => {
|
|
guards.delete(entry);
|
|
if (guards.size === 0) {
|
|
window.removeEventListener("beforeunload", beforeUnload);
|
|
document.removeEventListener("click", click, true);
|
|
navigation?.removeEventListener("navigate", traverse);
|
|
}
|
|
};
|
|
}, [dirty]);
|
|
const confirmLeave = useCallback(
|
|
() => !guard.current.dirty || window.confirm(guard.current.message),
|
|
[],
|
|
);
|
|
const markSaved = useCallback(() => {
|
|
guard.current.dirty = false;
|
|
}, []);
|
|
return { confirmLeave, markSaved };
|
|
}
|