mirror of
https://github.com/Sendouc/sendou.ink.git
synced 2026-09-27 21:58:08 -05:00
83 lines
2.6 KiB
TypeScript
83 lines
2.6 KiB
TypeScript
import * as React from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { type Location, useBlocker } from "react-router";
|
|
import { SendouButton } from "~/components/elements/Button";
|
|
import { SendouDialog } from "~/components/elements/Dialog";
|
|
|
|
/** In-app navigations pass the locations so same-route ones can be ignored; a page unload passes nothing. */
|
|
type UnsavedChangesChecker = (navigation?: {
|
|
currentLocation: Location;
|
|
nextLocation: Location;
|
|
}) => boolean;
|
|
|
|
const dirtyCheckers = new Set<UnsavedChangesChecker>();
|
|
|
|
/** Rendered once in root since react-router allows one active blocker; forms register via `useUnsavedChangesChecker`. */
|
|
export function UnsavedChangesGuard() {
|
|
const { t } = useTranslation(["common", "forms"]);
|
|
|
|
const blocker = useBlocker(
|
|
({ currentLocation, nextLocation }) =>
|
|
(currentLocation.pathname !== nextLocation.pathname ||
|
|
currentLocation.search !== nextLocation.search) &&
|
|
hasUnsavedChanges({ currentLocation, nextLocation }),
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
const handleBeforeUnload = (event: BeforeUnloadEvent) => {
|
|
if (!hasUnsavedChanges()) return;
|
|
event.preventDefault();
|
|
};
|
|
|
|
window.addEventListener("beforeunload", handleBeforeUnload);
|
|
return () => window.removeEventListener("beforeunload", handleBeforeUnload);
|
|
}, []);
|
|
|
|
if (blocker.state !== "blocked") return null;
|
|
|
|
return (
|
|
<SendouDialog
|
|
heading={t("forms:unsavedChanges.title")}
|
|
onClose={() => blocker.reset()}
|
|
isDismissable
|
|
>
|
|
<div className="stack md stack md text-sm text-lighter">
|
|
{t("forms:unsavedChanges.body")}
|
|
<div className="stack horizontal md justify-center">
|
|
<SendouButton variant="outlined" onClick={() => blocker.reset()}>
|
|
{t("common:actions.cancel")}
|
|
</SendouButton>
|
|
<SendouButton
|
|
variant="destructive"
|
|
onClick={() => blocker.proceed()}
|
|
data-testid="discard-changes-button"
|
|
>
|
|
{t("forms:unsavedChanges.discard")}
|
|
</SendouButton>
|
|
</div>
|
|
</div>
|
|
</SendouDialog>
|
|
);
|
|
}
|
|
|
|
/** Registers a dirty checker; the ref lets it read the latest state without re-registering every render. */
|
|
export function useUnsavedChangesChecker(
|
|
checkerRef: React.RefObject<UnsavedChangesChecker>,
|
|
) {
|
|
React.useEffect(() => {
|
|
const checker: UnsavedChangesChecker = (navigation) =>
|
|
checkerRef.current(navigation);
|
|
dirtyCheckers.add(checker);
|
|
return () => {
|
|
dirtyCheckers.delete(checker);
|
|
};
|
|
}, [checkerRef]);
|
|
}
|
|
|
|
function hasUnsavedChanges(navigation?: Parameters<UnsavedChangesChecker>[0]) {
|
|
for (const checker of dirtyCheckers) {
|
|
if (checker(navigation)) return true;
|
|
}
|
|
return false;
|
|
}
|