The guard shipped with "Discard changes" as the only choice carrying a colour -- a filled red button, against a plain outlined "Save changes" -- which made losing the work the loudest thing in a dialog whose entire purpose is to stop that. The emphasis belongs on the safe answer. A dialog choice can now be marked `primary`, and Save is. Discard keeps its `danger` flag, but a danger choice is drawn the way `.menu-item.danger` already is: a red label on the ordinary surface. In a list of answers a filled red button is not "this one is destructive", it is "this one is the default", which is the opposite of what it meant here. That rendering change reaches the other choice dialog too -- the calendar's "this occurrence or the whole series", where both answers are marked danger because both delete something. Two filled red buttons become two red labels and nothing is highlighted, which is right: neither answer there is the safe one, so neither should look like it. Checked in the browser against the mock, in both themes. Light: #dc2626 on white, 4.8:1. Dark: the theme's own --danger, which every palette already tunes for contrast on this surface. Reported on #175 by the reporter's colleague, who is right that the non-destructive action is the one that normally gets the highlight.
112 lines
4.1 KiB
TypeScript
112 lines
4.1 KiB
TypeScript
import { useEffect, useRef } from "react";
|
|
import { choiceDialog } from "@/ui/dialog";
|
|
import { t } from "@/lib/i18n";
|
|
|
|
/**
|
|
* Editors that would lose work if you walked away from them.
|
|
*
|
|
* The filter editors keep their edits in component state, so every way out of
|
|
* the page -- a settings link, the app rail, the Rules/Scripts switch -- threw
|
|
* them away without a word, and the only sign there had been anything to lose
|
|
* was a Save button that a screenful of rules had already pushed past the
|
|
* bottom of the window. That is issue #175.
|
|
*
|
|
* An editor registers what it has pending here; navigation asks before it
|
|
* happens. The question is deliberately not a yes/no: "leave without saving?"
|
|
* makes losing the work the easy answer and saving it the one you have to back
|
|
* out and find, when saving is what almost everybody wants.
|
|
*/
|
|
export interface UnsavedChanges {
|
|
/** Whether anything would be lost by leaving right now. */
|
|
dirty: boolean;
|
|
/**
|
|
* Persist the edits. `false` keeps you where you are: a save that failed is
|
|
* exactly the moment not to navigate, because the edits only exist here.
|
|
*/
|
|
save: () => Promise<boolean>;
|
|
/** Throw the edits away. */
|
|
discard: () => void;
|
|
/** Already translated, and specific: it says what is about to be lost. */
|
|
message: string;
|
|
}
|
|
|
|
/**
|
|
* Held by reference rather than by value, so the callbacks the dialog runs are
|
|
* the ones from the editor's latest render and not from whenever it mounted.
|
|
*/
|
|
type Slot = { current: UnsavedChanges };
|
|
|
|
const slots = new Set<Slot>();
|
|
|
|
/** Register this editor's pending edits for as long as it is on screen. */
|
|
export function useUnsavedChanges(changes: UnsavedChanges): void {
|
|
const slot = useRef(changes);
|
|
slot.current = changes;
|
|
useEffect(() => {
|
|
slots.add(slot);
|
|
return () => {
|
|
slots.delete(slot);
|
|
};
|
|
}, []);
|
|
/*
|
|
* Reloading and closing the tab are the browser's to ask about, and all it
|
|
* will show is its own generic wording -- custom text was removed years ago.
|
|
* Generic is still better than silent, and it costs one listener, armed only
|
|
* while there is something to lose.
|
|
*/
|
|
useEffect(() => {
|
|
if (!changes.dirty) return;
|
|
const warn = (e: BeforeUnloadEvent) => {
|
|
e.preventDefault();
|
|
e.returnValue = "";
|
|
};
|
|
window.addEventListener("beforeunload", warn);
|
|
return () => window.removeEventListener("beforeunload", warn);
|
|
}, [changes.dirty]);
|
|
}
|
|
|
|
/** Whether leaving now would lose anything. Cheap enough for every navigation. */
|
|
export function hasUnsavedChanges(): boolean {
|
|
for (const slot of slots) if (slot.current.dirty) return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Ask about anything pending. Resolves true when it is safe to go.
|
|
*
|
|
* Dismissing the dialog -- Escape, the backdrop, the close button -- is not
|
|
* one of the answers, so it cannot be mistaken for "discard": it leaves you on
|
|
* the page with the edits intact.
|
|
*/
|
|
export async function confirmLeaveUnsaved(): Promise<boolean> {
|
|
/*
|
|
* A snapshot, not the live set. `save` and `discard` both make an editor
|
|
* clean, but not until React has re-rendered it, and re-reading `slots`
|
|
* between questions would find the same editor still dirty and ask twice.
|
|
*/
|
|
for (const slot of [...slots]) {
|
|
const pending = slot.current;
|
|
if (!pending.dirty) continue;
|
|
const answer = await choiceDialog({
|
|
title: t("Save your changes?"),
|
|
message: pending.message,
|
|
choices: [
|
|
// Save is the highlighted one. Highlighting the destructive answer makes
|
|
// losing the work the easy thing to click, which is the failure this
|
|
// dialog exists to prevent -- #175, after the guard shipped with the
|
|
// emphasis the wrong way round.
|
|
{ value: "save", label: t("Save changes"), primary: true },
|
|
{ value: "discard", label: t("Discard changes"), hint: t("What you changed here will be lost."), danger: true },
|
|
],
|
|
cancelLabel: t("Stay here"),
|
|
});
|
|
if (answer === null) return false;
|
|
if (answer === "discard") {
|
|
pending.discard();
|
|
continue;
|
|
}
|
|
if (!(await pending.save())) return false;
|
|
}
|
|
return true;
|
|
}
|