feat(ux): toast system, accessible modal wrapper, safer dialogs

- Global toast stack (success/error/info) replacing hidden notice/syncError
- Shared Modal: focus trap, Escape, focus restore, scroll lock; all dialogs migrated
- ConfirmDialog tone prop; limit-override now warning-styled
- Single-entry delete now requires confirmation, per-row busy spinner
- Onboarding no longer closes on failed save; inline error + skip option
This commit is contained in:
nh9961
2026-07-19 20:08:14 +00:00
parent f8d06cbafb
commit 733c6cbae8
16 changed files with 857 additions and 520 deletions
+39 -7
View File
@@ -49,8 +49,9 @@ import {
parseUserLimits,
} from "./lib/userLimits";
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel";
import { groupByDay, groupByFlavour, groupByWeek, makeId } from "./lib/metrics";
import { groupByDay, groupByFlavour, groupByWeek, humanDateTime, makeId } from "./lib/metrics";
import { exportPayload, parseImport } from "./lib/storage";
import { useToasts } from "./lib/toasts";
import type {
AppView,
AuthUser,
@@ -103,6 +104,8 @@ function App() {
const [limitConfirmMessage, setLimitConfirmMessage] = useState("");
const [pendingLimitAction, setPendingLimitAction] = useState<PendingLimitAction | null>(null);
const [setupOpen, setSetupOpen] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const { pushToast } = useToasts();
const excelFileInputRef = useRef<HTMLInputElement>(null);
const jsonFileInputRef = useRef<HTMLInputElement>(null);
@@ -210,6 +213,14 @@ function App() {
return () => unsubscribe();
}, [refreshEntries, user]);
useEffect(() => {
if (notice) pushToast({ tone: "success", message: notice });
}, [notice, pushToast]);
useEffect(() => {
if (syncError) pushToast({ tone: "error", message: syncError });
}, [syncError, pushToast]);
const allFlavours = useMemo(
() => mergedFlavours(entries.map((entry) => entry.flavour)),
[entries],
@@ -225,6 +236,8 @@ function App() {
const flavourData = useMemo(() => groupByFlavour(entriesInView), [entriesInView]);
const insights = useMemo(() => buildInsights(entries), [entries]);
const recentEntries = useMemo(() => entries.slice(0, 5), [entries]);
const pendingDeleteEntry = pendingDeleteId ? entries.find((entry) => entry.id === pendingDeleteId) ?? null : null;
const deletingId = busyAction?.startsWith("delete-") ? busyAction.slice("delete-".length) : null;
async function login(email: string, password: string) {
setBusyAction("auth");
@@ -328,8 +341,8 @@ function App() {
}
}
async function saveOnboarding(limits: UserLimits, onboardingThemeId: string) {
if (!user) return;
async function saveOnboarding(limits: UserLimits, onboardingThemeId: string): Promise<boolean> {
if (!user) return false;
setBusyAction("save-onboarding");
setSyncError("");
try {
@@ -347,8 +360,10 @@ function App() {
setThemeId(onboardingThemeId);
setSetupOpen(false);
setNotice("Setup saved.");
return true;
} catch (error) {
setSyncError(appwriteErrorMessage(error));
return false;
} finally {
setBusyAction(null);
}
@@ -435,6 +450,7 @@ function App() {
try {
await deleteEntryDocument(id);
setEntries((current) => current.filter((entry) => entry.id !== id));
setPendingDeleteId(null);
setNotice("Entry deleted from Appwrite.");
} catch (error) {
setSyncError(appwriteErrorMessage(error));
@@ -599,7 +615,6 @@ function App() {
<Sidebar
activeView={activeView}
dataLoading={dataLoading}
notice={notice}
setupStatus={setupStatus}
user={user}
onAdd={openNewEntry}
@@ -617,7 +632,7 @@ function App() {
className={activeView === "overview" ? "top-app-bar--overview" : ""}
/>
<StatusRail busyAction={busyAction} syncError={syncError} setupStatus={setupStatus} />
<StatusRail busyAction={busyAction} setupStatus={setupStatus} />
<AnimatePresence mode="wait">
<motion.main
@@ -654,13 +669,14 @@ function App() {
totalEntries={entries.length}
filters={filters}
flavours={allFlavours}
deletingId={deletingId}
onFilterChange={setFilters}
onAdd={openNewEntry}
onEdit={(entry) => {
setEditingEntry(entry);
setIsEntryModalOpen(true);
}}
onDelete={(id) => void deleteEntry(id)}
onDelete={(id) => setPendingDeleteId(id)}
/>
)}
@@ -685,7 +701,6 @@ function App() {
summary={summary}
dataLoading={dataLoading}
entries={entries}
notice={notice}
setupStatus={setupStatus}
themeId={themeId}
themeMode={themeMode}
@@ -753,12 +768,29 @@ function App() {
onConfirm={() => void resetAll()}
/>
<ConfirmDialog
busy={pendingDeleteId !== null && busyAction === `delete-${pendingDeleteId}`}
open={pendingDeleteId !== null}
title="Delete this entry?"
body={
pendingDeleteEntry
? `${pendingDeleteEntry.flavour} · ${pendingDeleteEntry.cans} can${pendingDeleteEntry.cans === 1 ? "" : "s"} · ${pendingDeleteEntry.sizeMl}ml · ${humanDateTime(pendingDeleteEntry.dateTime)}`
: "This entry will be permanently removed."
}
confirmLabel="Delete"
onCancel={() => setPendingDeleteId(null)}
onConfirm={() => {
if (pendingDeleteId) void deleteEntry(pendingDeleteId);
}}
/>
<ConfirmDialog
busy={Boolean(busyAction && pendingLimitAction)}
open={limitConfirmOpen}
title="Over your limit?"
body={limitConfirmMessage || "This intake goes past one of your daily limits."}
confirmLabel="Log anyway"
tone="warning"
onCancel={() => {
setLimitConfirmOpen(false);
setPendingLimitAction(null);