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, parseUserLimits,
} from "./lib/userLimits"; } from "./lib/userLimits";
import { createExcelExport, downloadBlob, parseExcelImport } from "./lib/excel"; 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 { exportPayload, parseImport } from "./lib/storage";
import { useToasts } from "./lib/toasts";
import type { import type {
AppView, AppView,
AuthUser, AuthUser,
@@ -103,6 +104,8 @@ function App() {
const [limitConfirmMessage, setLimitConfirmMessage] = useState(""); const [limitConfirmMessage, setLimitConfirmMessage] = useState("");
const [pendingLimitAction, setPendingLimitAction] = useState<PendingLimitAction | null>(null); const [pendingLimitAction, setPendingLimitAction] = useState<PendingLimitAction | null>(null);
const [setupOpen, setSetupOpen] = useState(false); const [setupOpen, setSetupOpen] = useState(false);
const [pendingDeleteId, setPendingDeleteId] = useState<string | null>(null);
const { pushToast } = useToasts();
const excelFileInputRef = useRef<HTMLInputElement>(null); const excelFileInputRef = useRef<HTMLInputElement>(null);
const jsonFileInputRef = useRef<HTMLInputElement>(null); const jsonFileInputRef = useRef<HTMLInputElement>(null);
@@ -210,6 +213,14 @@ function App() {
return () => unsubscribe(); return () => unsubscribe();
}, [refreshEntries, user]); }, [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( const allFlavours = useMemo(
() => mergedFlavours(entries.map((entry) => entry.flavour)), () => mergedFlavours(entries.map((entry) => entry.flavour)),
[entries], [entries],
@@ -225,6 +236,8 @@ function App() {
const flavourData = useMemo(() => groupByFlavour(entriesInView), [entriesInView]); const flavourData = useMemo(() => groupByFlavour(entriesInView), [entriesInView]);
const insights = useMemo(() => buildInsights(entries), [entries]); const insights = useMemo(() => buildInsights(entries), [entries]);
const recentEntries = useMemo(() => entries.slice(0, 5), [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) { async function login(email: string, password: string) {
setBusyAction("auth"); setBusyAction("auth");
@@ -328,8 +341,8 @@ function App() {
} }
} }
async function saveOnboarding(limits: UserLimits, onboardingThemeId: string) { async function saveOnboarding(limits: UserLimits, onboardingThemeId: string): Promise<boolean> {
if (!user) return; if (!user) return false;
setBusyAction("save-onboarding"); setBusyAction("save-onboarding");
setSyncError(""); setSyncError("");
try { try {
@@ -347,8 +360,10 @@ function App() {
setThemeId(onboardingThemeId); setThemeId(onboardingThemeId);
setSetupOpen(false); setSetupOpen(false);
setNotice("Setup saved."); setNotice("Setup saved.");
return true;
} catch (error) { } catch (error) {
setSyncError(appwriteErrorMessage(error)); setSyncError(appwriteErrorMessage(error));
return false;
} finally { } finally {
setBusyAction(null); setBusyAction(null);
} }
@@ -435,6 +450,7 @@ function App() {
try { try {
await deleteEntryDocument(id); await deleteEntryDocument(id);
setEntries((current) => current.filter((entry) => entry.id !== id)); setEntries((current) => current.filter((entry) => entry.id !== id));
setPendingDeleteId(null);
setNotice("Entry deleted from Appwrite."); setNotice("Entry deleted from Appwrite.");
} catch (error) { } catch (error) {
setSyncError(appwriteErrorMessage(error)); setSyncError(appwriteErrorMessage(error));
@@ -599,7 +615,6 @@ function App() {
<Sidebar <Sidebar
activeView={activeView} activeView={activeView}
dataLoading={dataLoading} dataLoading={dataLoading}
notice={notice}
setupStatus={setupStatus} setupStatus={setupStatus}
user={user} user={user}
onAdd={openNewEntry} onAdd={openNewEntry}
@@ -617,7 +632,7 @@ function App() {
className={activeView === "overview" ? "top-app-bar--overview" : ""} className={activeView === "overview" ? "top-app-bar--overview" : ""}
/> />
<StatusRail busyAction={busyAction} syncError={syncError} setupStatus={setupStatus} /> <StatusRail busyAction={busyAction} setupStatus={setupStatus} />
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.main <motion.main
@@ -654,13 +669,14 @@ function App() {
totalEntries={entries.length} totalEntries={entries.length}
filters={filters} filters={filters}
flavours={allFlavours} flavours={allFlavours}
deletingId={deletingId}
onFilterChange={setFilters} onFilterChange={setFilters}
onAdd={openNewEntry} onAdd={openNewEntry}
onEdit={(entry) => { onEdit={(entry) => {
setEditingEntry(entry); setEditingEntry(entry);
setIsEntryModalOpen(true); setIsEntryModalOpen(true);
}} }}
onDelete={(id) => void deleteEntry(id)} onDelete={(id) => setPendingDeleteId(id)}
/> />
)} )}
@@ -685,7 +701,6 @@ function App() {
summary={summary} summary={summary}
dataLoading={dataLoading} dataLoading={dataLoading}
entries={entries} entries={entries}
notice={notice}
setupStatus={setupStatus} setupStatus={setupStatus}
themeId={themeId} themeId={themeId}
themeMode={themeMode} themeMode={themeMode}
@@ -753,12 +768,29 @@ function App() {
onConfirm={() => void resetAll()} 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 <ConfirmDialog
busy={Boolean(busyAction && pendingLimitAction)} busy={Boolean(busyAction && pendingLimitAction)}
open={limitConfirmOpen} open={limitConfirmOpen}
title="Over your limit?" title="Over your limit?"
body={limitConfirmMessage || "This intake goes past one of your daily limits."} body={limitConfirmMessage || "This intake goes past one of your daily limits."}
confirmLabel="Log anyway" confirmLabel="Log anyway"
tone="warning"
onCancel={() => { onCancel={() => {
setLimitConfirmOpen(false); setLimitConfirmOpen(false);
setPendingLimitAction(null); setPendingLimitAction(null);
+9 -22
View File
@@ -1,5 +1,5 @@
import { AlertTriangle, Camera, Keyboard, Loader2, ScanLine, X } from "lucide-react"; import { AlertTriangle, Camera, Keyboard, Loader2, ScanLine, X } from "lucide-react";
import { AnimatePresence, motion } from "framer-motion"; import { motion } from "framer-motion";
import { import {
useCallback, useCallback,
useEffect, useEffect,
@@ -39,6 +39,7 @@ import type {
UserBarcodeMapping, UserBarcodeMapping,
} from "../types"; } from "../types";
import { BarcodeProductPreview } from "./BarcodeProductPreview"; import { BarcodeProductPreview } from "./BarcodeProductPreview";
import { Modal } from "./Modal";
type ScannerPhase = "idle" | "starting" | "scanning" | "found" | "manual" | "error"; type ScannerPhase = "idle" | "starting" | "scanning" | "found" | "manual" | "error";
@@ -188,7 +189,6 @@ export function BarcodeScannerModal({
setManualMessage(""); setManualMessage("");
setMappingSaving(false); setMappingSaving(false);
applyManualDefaults(); applyManualDefaults();
window.setTimeout(() => closeButtonRef.current?.focus(), 80);
let active = true; let active = true;
let frameId = 0; let frameId = 0;
@@ -237,15 +237,6 @@ export function BarcodeScannerModal({
}; };
}, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]); }, [applyManualDefaults, handleScannerError, handleScannerResult, open, stopScanner, userId]);
useEffect(() => {
if (!open) return undefined;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose, open]);
function submitTypedBarcode(event: FormEvent<HTMLFormElement>) { function submitTypedBarcode(event: FormEvent<HTMLFormElement>) {
event.preventDefault(); event.preventDefault();
resolveBarcodeValue(typedBarcode); resolveBarcodeValue(typedBarcode);
@@ -307,17 +298,14 @@ export function BarcodeScannerModal({
: "Scanner paused"; : "Scanner paused";
return ( return (
<AnimatePresence> <Modal
{open && ( open={open}
<motion.div onClose={onClose}
labelledBy="barcode-scanner-title"
initialFocusRef={closeButtonRef}
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/70 backdrop-blur-xl" className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/70 backdrop-blur-xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
role="dialog"
aria-modal="true"
aria-labelledby="barcode-scanner-title"
> >
{open && (
<motion.div <motion.div
className="modal-panel max-w-4xl" className="modal-panel max-w-4xl"
initial={{ opacity: 0, y: 18, scale: 0.98 }} initial={{ opacity: 0, y: 18, scale: 0.98 }}
@@ -528,9 +516,8 @@ export function BarcodeScannerModal({
</section> </section>
</div> </div>
</motion.div> </motion.div>
</motion.div>
)} )}
</AnimatePresence> </Modal>
); );
} }
+15 -18
View File
@@ -1,5 +1,6 @@
import { AnimatePresence, motion } from "framer-motion"; import { motion } from "framer-motion";
import { Loader2, Trash2 } from "lucide-react"; import { AlertTriangle, Loader2, Trash2 } from "lucide-react";
import { Modal } from "./Modal";
type ConfirmDialogProps = { type ConfirmDialogProps = {
busy: boolean; busy: boolean;
@@ -7,6 +8,7 @@ type ConfirmDialogProps = {
title: string; title: string;
body: string; body: string;
confirmLabel: string; confirmLabel: string;
tone?: "danger" | "warning";
onCancel: () => void; onCancel: () => void;
onConfirm: () => void; onConfirm: () => void;
}; };
@@ -17,21 +19,13 @@ export function ConfirmDialog({
title, title,
body, body,
confirmLabel, confirmLabel,
tone = "danger",
onCancel, onCancel,
onConfirm, onConfirm,
}: ConfirmDialogProps) { }: ConfirmDialogProps) {
const ConfirmIcon = tone === "warning" ? AlertTriangle : Trash2;
return ( return (
<AnimatePresence> <Modal open={open} onClose={onCancel} labelledBy="confirm-title">
{open && (
<motion.div
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/60 backdrop-blur-xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
role="dialog"
aria-modal="true"
aria-labelledby="confirm-title"
>
<motion.div <motion.div
className="glass-panel w-full max-w-md p-5" className="glass-panel w-full max-w-md p-5"
initial={{ opacity: 0, y: 16, scale: 0.98 }} initial={{ opacity: 0, y: 16, scale: 0.98 }}
@@ -46,14 +40,17 @@ export function ConfirmDialog({
<button className="secondary-button justify-center" type="button" onClick={onCancel}> <button className="secondary-button justify-center" type="button" onClick={onCancel}>
Cancel Cancel
</button> </button>
<button className="danger-button justify-center" type="button" onClick={onConfirm} disabled={busy}> <button
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Trash2 size={17} aria-hidden="true" />} className={`${tone === "warning" ? "warning-button" : "danger-button"} justify-center`}
type="button"
onClick={onConfirm}
disabled={busy}
>
{busy ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <ConfirmIcon size={17} aria-hidden="true" />}
{confirmLabel} {confirmLabel}
</button> </button>
</div> </div>
</motion.div> </motion.div>
</motion.div> </Modal>
)}
</AnimatePresence>
); );
} }
+15 -5
View File
@@ -1,4 +1,4 @@
import { Edit3, Trash2 } from "lucide-react"; import { Edit3, Loader2, Trash2 } from "lucide-react";
import type { CSSProperties } from "react"; import type { CSSProperties } from "react";
import { caffeineFor, currency, humanDateTime, oneDecimal, spendFor, sugarFor, wholeNumber } from "../lib/metrics"; import { caffeineFor, currency, humanDateTime, oneDecimal, spendFor, sugarFor, wholeNumber } from "../lib/metrics";
import { AppCard } from "./AppCard"; import { AppCard } from "./AppCard";
@@ -8,18 +8,19 @@ import type { RedBullEntry } from "../types";
type EntryLedgerProps = { type EntryLedgerProps = {
entries: RedBullEntry[]; entries: RedBullEntry[];
totalEntries: number; totalEntries: number;
deletingId: string | null;
onAdd: () => void; onAdd: () => void;
onEdit: (entry: RedBullEntry) => void; onEdit: (entry: RedBullEntry) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
}; };
export function EntryLedger({ entries, totalEntries, onAdd, onEdit, onDelete }: EntryLedgerProps) { export function EntryLedger({ entries, totalEntries, deletingId, onAdd, onEdit, onDelete }: EntryLedgerProps) {
return ( return (
<AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}> <AppCard title="Entries" subtitle={`${entries.length} visible of ${totalEntries}`}>
{entries.length ? ( {entries.length ? (
<div className="grid gap-2"> <div className="grid gap-2">
{entries.map((entry) => ( {entries.map((entry) => (
<EntryRow key={entry.id} entry={entry} onEdit={onEdit} onDelete={onDelete} /> <EntryRow key={entry.id} entry={entry} deleting={deletingId === entry.id} onEdit={onEdit} onDelete={onDelete} />
))} ))}
</div> </div>
) : ( ) : (
@@ -31,10 +32,12 @@ export function EntryLedger({ entries, totalEntries, onAdd, onEdit, onDelete }:
function EntryRow({ function EntryRow({
entry, entry,
deleting,
onEdit, onEdit,
onDelete, onDelete,
}: { }: {
entry: RedBullEntry; entry: RedBullEntry;
deleting: boolean;
onEdit: (entry: RedBullEntry) => void; onEdit: (entry: RedBullEntry) => void;
onDelete: (id: string) => void; onDelete: (id: string) => void;
}) { }) {
@@ -67,8 +70,15 @@ function EntryRow({
<button className="icon-button" type="button" onClick={() => onEdit(entry)} aria-label={`Edit ${entry.flavour} entry`}> <button className="icon-button" type="button" onClick={() => onEdit(entry)} aria-label={`Edit ${entry.flavour} entry`}>
<Edit3 size={17} aria-hidden="true" /> <Edit3 size={17} aria-hidden="true" />
</button> </button>
<button className="icon-button" type="button" style={{ color: "#9f1c16" }} onClick={() => onDelete(entry.id)} aria-label={`Delete ${entry.flavour} entry`}> <button
<Trash2 size={17} aria-hidden="true" /> className="icon-button"
type="button"
style={{ color: "#9f1c16" }}
disabled={deleting}
onClick={() => onDelete(entry.id)}
aria-label={`Delete ${entry.flavour} entry`}
>
{deleting ? <Loader2 className="animate-spin" size={17} aria-hidden="true" /> : <Trash2 size={17} aria-hidden="true" />}
</button> </button>
</div> </div>
</article> </article>
+4 -25
View File
@@ -1,9 +1,10 @@
import { AnimatePresence, motion } from "framer-motion"; import { motion } from "framer-motion";
import { Loader2, Plus, X } from "lucide-react"; import { Loader2, Plus, X } from "lucide-react";
import { useEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent } from "react"; import { useEffect, useMemo, useRef, useState, type CSSProperties, type FormEvent } from "react";
import { MATERIAL_ACCENTS } from "../data/accents"; import { MATERIAL_ACCENTS } from "../data/accents";
import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours"; import { BUILT_IN_FLAVOURS, DEFAULT_FLAVOUR, accentForCustomFlavour, flavourMeta } from "../data/flavours";
import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics"; import { caffeinePerCan, defaultPriceForSize, formatLocalInput, wholeNumber } from "../lib/metrics";
import { Modal } from "./Modal";
import type { EntryDraft, Flavour, RedBullEntry } from "../types"; import type { EntryDraft, Flavour, RedBullEntry } from "../types";
type EntryModalProps = { type EntryModalProps = {
@@ -57,18 +58,8 @@ export function EntryModal({
setNotes(draft?.notes ?? ""); setNotes(draft?.notes ?? "");
setSugarFree(draft?.sugarFree ?? false); setSugarFree(draft?.sugarFree ?? false);
setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? ""); setCaffeineOverride(draft?.caffeineMgPerCan?.toString() ?? "");
window.setTimeout(() => firstFieldRef.current?.focus(), 80);
}, [entry, initialDraft, open]); }, [entry, initialDraft, open]);
useEffect(() => {
if (!open) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") onClose();
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [onClose, open]);
const selectedMeta = flavourMeta(selectedFlavour); const selectedMeta = flavourMeta(selectedFlavour);
const isOther = selectedFlavour === "Other"; const isOther = selectedFlavour === "Other";
const numericSize = Math.max(1, sizePreset === "custom" ? Number(customSize) || 250 : Number(sizePreset)); const numericSize = Math.max(1, sizePreset === "custom" ? Number(customSize) || 250 : Number(sizePreset));
@@ -126,17 +117,7 @@ export function EntryModal({
} }
return ( return (
<AnimatePresence> <Modal open={open} onClose={onClose} labelledBy="entry-modal-title" initialFocusRef={firstFieldRef}>
{open && (
<motion.div
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/60 backdrop-blur-xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
role="dialog"
aria-modal="true"
aria-labelledby="entry-modal-title"
>
<motion.form <motion.form
className="modal-panel" className="modal-panel"
onSubmit={submit} onSubmit={submit}
@@ -273,9 +254,7 @@ export function EntryModal({
</button> </button>
</div> </div>
</motion.form> </motion.form>
</motion.div> </Modal>
)}
</AnimatePresence>
); );
} }
+4 -13
View File
@@ -1,7 +1,8 @@
import { AnimatePresence, motion } from "framer-motion"; import { motion } from "framer-motion";
import { FileSpreadsheet, Loader2, X } from "lucide-react"; import { FileSpreadsheet, Loader2, X } from "lucide-react";
import { humanDateTime } from "../lib/metrics"; import { humanDateTime } from "../lib/metrics";
import { MiniMetric } from "./MetricTiles"; import { MiniMetric } from "./MetricTiles";
import { Modal } from "./Modal";
import type { ImportPreview } from "../types"; import type { ImportPreview } from "../types";
type ImportPreviewModalProps = { type ImportPreviewModalProps = {
@@ -22,17 +23,8 @@ export function ImportPreviewModal({
const duplicateRows = preview?.rows.filter((row) => row.duplicate) ?? []; const duplicateRows = preview?.rows.filter((row) => row.duplicate) ?? [];
return ( return (
<AnimatePresence> <Modal open={Boolean(preview)} onClose={onClose} labelledBy="import-preview-title">
{preview && ( {preview && (
<motion.div
className="modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/60 backdrop-blur-xl"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
role="dialog"
aria-modal="true"
aria-labelledby="import-preview-title"
>
<motion.div <motion.div
className="modal-panel max-w-5xl" className="modal-panel max-w-5xl"
initial={{ opacity: 0, y: 18, scale: 0.98 }} initial={{ opacity: 0, y: 18, scale: 0.98 }}
@@ -101,8 +93,7 @@ export function ImportPreviewModal({
</button> </button>
</div> </div>
</motion.div> </motion.div>
</motion.div>
)} )}
</AnimatePresence> </Modal>
); );
} }
+133
View File
@@ -0,0 +1,133 @@
import { AnimatePresence, motion } from "framer-motion";
import { useEffect, useRef, type ReactNode, type RefObject } from "react";
const FOCUSABLE_SELECTOR =
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])';
const DEFAULT_BACKDROP_CLASS =
"modal-backdrop fixed inset-0 z-50 flex justify-center bg-black/60 backdrop-blur-xl";
// Tracks open modals in stacking order so only the topmost one answers
// keyboard events when dialogs are layered (e.g. limit confirm over EntryModal).
const openModalIds: number[] = [];
let modalIdCounter = 0;
type ModalProps = {
open: boolean;
onClose: () => void;
labelledBy?: string;
label?: string;
closeOnEscape?: boolean;
closeOnBackdrop?: boolean;
initialFocusRef?: RefObject<HTMLElement>;
className?: string;
children: ReactNode;
};
export function Modal({
open,
onClose,
labelledBy,
label,
closeOnEscape = true,
closeOnBackdrop = false,
initialFocusRef,
className = DEFAULT_BACKDROP_CLASS,
children,
}: ModalProps) {
const backdropRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
const modalIdRef = useRef<number | null>(null);
if (modalIdRef.current === null) modalIdRef.current = ++modalIdCounter;
useEffect(() => {
onCloseRef.current = onClose;
}, [onClose]);
useEffect(() => {
if (!open) return undefined;
const modalId = modalIdRef.current as number;
openModalIds.push(modalId);
const isTopmost = () => openModalIds[openModalIds.length - 1] === modalId;
const backdrop = backdropRef.current;
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
const focusTimer = window.setTimeout(() => {
if (!isTopmost()) return;
if (!backdrop) return;
const target = initialFocusRef?.current ?? backdrop.querySelector<HTMLElement>(FOCUSABLE_SELECTOR);
if (target) target.focus();
else backdrop.focus();
}, 80);
const onKeyDown = (event: KeyboardEvent) => {
if (!isTopmost()) return;
if (event.key === "Escape") {
if (closeOnEscape) onCloseRef.current();
return;
}
if (event.key !== "Tab") return;
if (!backdrop) return;
const focusable = Array.from(backdrop.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR));
if (!focusable.length) {
event.preventDefault();
return;
}
const first = focusable[0];
const last = focusable[focusable.length - 1];
const active = document.activeElement;
if (event.shiftKey) {
if (active === first || !backdrop.contains(active)) {
event.preventDefault();
last.focus();
}
} else if (active === last || !backdrop.contains(active)) {
event.preventDefault();
first.focus();
}
};
window.addEventListener("keydown", onKeyDown);
return () => {
const stackIndex = openModalIds.indexOf(modalId);
if (stackIndex !== -1) openModalIds.splice(stackIndex, 1);
window.clearTimeout(focusTimer);
window.removeEventListener("keydown", onKeyDown);
document.body.style.overflow = previousOverflow;
const active = document.activeElement;
if (
previouslyFocused &&
(!active || active === document.body || (backdrop?.contains(active) ?? false))
) {
previouslyFocused.focus();
}
};
}, [open, closeOnEscape, initialFocusRef]);
return (
<AnimatePresence>
{open && (
<motion.div
ref={backdropRef}
className={className}
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
role="dialog"
aria-modal="true"
aria-labelledby={labelledBy}
aria-label={label}
tabIndex={-1}
onClick={(event) => {
if (closeOnBackdrop && event.target === event.currentTarget) onCloseRef.current();
}}
>
{children}
</motion.div>
)}
</AnimatePresence>
);
}
+27 -4
View File
@@ -10,7 +10,7 @@ import {
import type { BuiltInSize, UserLimits } from "../types"; import type { BuiltInSize, UserLimits } from "../types";
type OnboardingScreenProps = { type OnboardingScreenProps = {
onSave: (limits: UserLimits, themeId: string) => Promise<void>; onSave: (limits: UserLimits, themeId: string) => Promise<boolean>;
onClose: () => void; onClose: () => void;
activeThemeId: string; activeThemeId: string;
onThemeChange: (themeId: string) => void; onThemeChange: (themeId: string) => void;
@@ -44,6 +44,7 @@ export function OnboardingScreen({
); );
const [stopTime, setStopTime] = useState<string | "none">(initialLimits?.stopTime ?? "18:00"); const [stopTime, setStopTime] = useState<string | "none">(initialLimits?.stopTime ?? "18:00");
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const activeTheme = useMemo(() => { const activeTheme = useMemo(() => {
return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0]; return APP_THEMES.find((theme) => theme.id === activeThemeId) ?? APP_THEMES[0];
}, [activeThemeId]); }, [activeThemeId]);
@@ -56,6 +57,7 @@ export function OnboardingScreen({
async function handleFinish() { async function handleFinish() {
setSaving(true); setSaving(true);
setSaveError("");
try { try {
const limits: UserLimits = {}; const limits: UserLimits = {};
if (dailyCanLimit !== "none") { if (dailyCanLimit !== "none") {
@@ -65,10 +67,14 @@ export function OnboardingScreen({
} }
if (stopTime !== "none") limits.stopTime = stopTime; if (stopTime !== "none") limits.stopTime = stopTime;
await onSave(limits, activeThemeId); const saved = await onSave(limits, activeThemeId);
if (saved) {
onClose(); onClose();
} catch (err) { } else {
console.error("setup save failed", err); setSaveError("Couldn't save your setup — check your connection and try again.");
}
} catch {
setSaveError("Couldn't save your setup — check your connection and try again.");
} finally { } finally {
setSaving(false); setSaving(false);
} }
@@ -404,6 +410,13 @@ export function OnboardingScreen({
</div> </div>
</div> </div>
{saveError && (
<p className="max-w-xl text-sm font-normal" style={{ color: "var(--error)" }} role="alert">
{saveError}
</p>
)}
<div className="flex flex-wrap items-center gap-3">
<button <button
type="button" type="button"
onClick={() => void handleFinish()} onClick={() => void handleFinish()}
@@ -414,6 +427,16 @@ export function OnboardingScreen({
{saving ? "Saving..." : "Start tracking"} {saving ? "Saving..." : "Start tracking"}
{!saving && <ArrowRight size={16} />} {!saving && <ArrowRight size={16} />}
</button> </button>
<button
type="button"
onClick={onClose}
disabled={saving}
className="inline-flex min-h-12 w-fit items-center rounded-full border px-6 text-sm font-medium transition active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60"
style={{ borderColor: "var(--outline-variant)", color: "var(--muted)" }}
>
Skip for now
</button>
</div>
</section> </section>
)} )}
</main> </main>
+1 -4
View File
@@ -6,7 +6,6 @@ import type { AppView, AuthUser, SetupStatus } from "../types";
type SidebarProps = { type SidebarProps = {
activeView: AppView; activeView: AppView;
dataLoading: boolean; dataLoading: boolean;
notice: string;
setupStatus: SetupStatus; setupStatus: SetupStatus;
user: AuthUser; user: AuthUser;
onAdd: () => void; onAdd: () => void;
@@ -18,7 +17,6 @@ type SidebarProps = {
export function Sidebar({ export function Sidebar({
activeView, activeView,
dataLoading, dataLoading,
notice,
setupStatus, setupStatus,
user, user,
onAdd, onAdd,
@@ -72,8 +70,7 @@ export function Sidebar({
{dataLoading ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Cloud size={15} aria-hidden="true" />} {dataLoading ? <Loader2 className="animate-spin" size={15} aria-hidden="true" /> : <Cloud size={15} aria-hidden="true" />}
Sync Sync
</div> </div>
<p className="text-sm leading-5 text-slate-700">{notice}</p> <p className={`text-xs ${setupStatus.state === "ok" ? "text-emerald-700" : "text-amber-700"}`}>{setupStatus.message}</p>
<p className={`mt-2 text-xs ${setupStatus.state === "ok" ? "text-emerald-700" : "text-amber-700"}`}>{setupStatus.message}</p>
</div> </div>
<button className="account-pill" type="button" onClick={onOpenSettings}> <button className="account-pill" type="button" onClick={onOpenSettings}>
+2 -9
View File
@@ -3,12 +3,11 @@ import type { SetupStatus } from "../types";
type StatusRailProps = { type StatusRailProps = {
busyAction: string | null; busyAction: string | null;
syncError: string;
setupStatus: SetupStatus; setupStatus: SetupStatus;
}; };
export function StatusRail({ busyAction, syncError, setupStatus }: StatusRailProps) { export function StatusRail({ busyAction, setupStatus }: StatusRailProps) {
if (!busyAction && !syncError && setupStatus.state === "ok") return null; if (!busyAction && setupStatus.state === "ok") return null;
return ( return (
<div className="mt-3 grid gap-2"> <div className="mt-3 grid gap-2">
{busyAction && ( {busyAction && (
@@ -17,12 +16,6 @@ export function StatusRail({ busyAction, syncError, setupStatus }: StatusRailPro
Working on {actionLabel(busyAction)}... Working on {actionLabel(busyAction)}...
</div> </div>
)} )}
{syncError && (
<div className="status-card status-card--error">
<AlertTriangle size={17} aria-hidden="true" />
{syncError}
</div>
)}
{setupStatus.state === "error" && ( {setupStatus.state === "error" && (
<div className="status-card status-card--warning"> <div className="status-card status-card--warning">
<AlertTriangle size={17} aria-hidden="true" /> <AlertTriangle size={17} aria-hidden="true" />
+77
View File
@@ -0,0 +1,77 @@
import { AnimatePresence, motion } from "framer-motion";
import { AlertTriangle, CheckCircle2, Info, X } from "lucide-react";
import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
import { ToastContext, type ToastInput, type ToastTone } from "../lib/toasts";
const TOAST_DURATION_MS = 4500;
const MAX_TOASTS = 4;
// Identical tone+message repeats inside this window are swallowed so
// StrictMode double effects and rapid state re-sets never double-fire.
const DEDUPE_WINDOW_MS = 1200;
type Toast = ToastInput & { id: number };
const TONE_ICONS: Record<ToastTone, typeof Info> = {
success: CheckCircle2,
error: AlertTriangle,
info: Info,
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const nextIdRef = useRef(0);
const lastToastRef = useRef<(ToastInput & { at: number }) | null>(null);
const dismissToast = useCallback((id: number) => {
setToasts((current) => current.filter((toast) => toast.id !== id));
}, []);
const pushToast = useCallback(
({ tone, message }: ToastInput) => {
const now = Date.now();
const last = lastToastRef.current;
if (last && last.tone === tone && last.message === message && now - last.at < DEDUPE_WINDOW_MS) return;
lastToastRef.current = { tone, message, at: now };
const id = ++nextIdRef.current;
setToasts((current) => [...current, { id, tone, message }].slice(-MAX_TOASTS));
window.setTimeout(() => dismissToast(id), TOAST_DURATION_MS);
},
[dismissToast],
);
const value = useMemo(() => ({ pushToast }), [pushToast]);
return (
<ToastContext.Provider value={value}>
{children}
<div className="toast-stack" role="status" aria-live="polite">
<AnimatePresence initial={false}>
{toasts.map((toast) => {
const ToneIcon = TONE_ICONS[toast.tone];
return (
<motion.div
key={toast.id}
className={`toast toast--${toast.tone}`}
initial={{ opacity: 0, y: -12, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -8, scale: 0.96 }}
transition={{ duration: 0.18 }}
>
<ToneIcon className="toast-icon" size={18} aria-hidden="true" />
<p className="toast-message">{toast.message}</p>
<button
className="toast-dismiss"
type="button"
onClick={() => dismissToast(toast.id)}
aria-label="Dismiss notification"
>
<X size={15} aria-hidden="true" />
</button>
</motion.div>
);
})}
</AnimatePresence>
</div>
</ToastContext.Provider>
);
}
+3 -1
View File
@@ -8,6 +8,7 @@ type LogbookViewProps = {
totalEntries: number; totalEntries: number;
filters: Filters; filters: Filters;
flavours: Flavour[]; flavours: Flavour[];
deletingId: string | null;
onFilterChange: (filters: Filters) => void; onFilterChange: (filters: Filters) => void;
onAdd: () => void; onAdd: () => void;
onEdit: (entry: RedBullEntry) => void; onEdit: (entry: RedBullEntry) => void;
@@ -19,6 +20,7 @@ export function LogbookView({
totalEntries, totalEntries,
filters, filters,
flavours, flavours,
deletingId,
onFilterChange, onFilterChange,
onAdd, onAdd,
onEdit, onEdit,
@@ -28,7 +30,7 @@ export function LogbookView({
<section className="grid gap-4"> <section className="grid gap-4">
<div className="logbook-layout grid gap-4"> <div className="logbook-layout grid gap-4">
<FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} /> <FiltersPanel filters={filters} flavours={flavours} onChange={onFilterChange} />
<EntryLedger entries={entries} totalEntries={totalEntries} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} /> <EntryLedger entries={entries} totalEntries={totalEntries} deletingId={deletingId} onAdd={onAdd} onEdit={onEdit} onDelete={onDelete} />
</div> </div>
<LegalFootnote /> <LegalFootnote />
</section> </section>
+1 -4
View File
@@ -16,7 +16,6 @@ type SettingsViewProps = {
summary: Dashboard; summary: Dashboard;
dataLoading: boolean; dataLoading: boolean;
entries: RedBullEntry[]; entries: RedBullEntry[];
notice: string;
setupStatus: SetupStatus; setupStatus: SetupStatus;
themeId: string; themeId: string;
themeMode: ThemeMode; themeMode: ThemeMode;
@@ -41,7 +40,6 @@ export function SettingsView({
summary, summary,
dataLoading, dataLoading,
entries, entries,
notice,
setupStatus, setupStatus,
themeId, themeId,
themeMode, themeMode,
@@ -145,9 +143,8 @@ export function SettingsView({
<div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.05] p-4"> <div className="mt-5 rounded-2xl border border-white/10 bg-white/[0.05] p-4">
<div className="flex items-center gap-2 text-sm text-slate-700"> <div className="flex items-center gap-2 text-sm text-slate-700">
{dataLoading ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Cloud size={16} aria-hidden="true" />} {dataLoading ? <Loader2 className="animate-spin" size={16} aria-hidden="true" /> : <Cloud size={16} aria-hidden="true" />}
{notice} {setupStatus.message}
</div> </div>
<p className={`mt-2 text-xs ${setupStatus.state === "ok" ? "text-emerald-700" : "text-amber-700"}`}>{setupStatus.message}</p>
</div> </div>
<button className="secondary-button mt-4 justify-center" type="button" onClick={onLogout}> <button className="secondary-button mt-4 justify-center" type="button" onClick={onLogout}>
<LogOut size={17} aria-hidden="true" /> <LogOut size={17} aria-hidden="true" />
+96
View File
@@ -165,6 +165,7 @@ textarea:focus-visible {
.primary-button, .primary-button,
.secondary-button, .secondary-button,
.danger-button, .danger-button,
.warning-button,
.excel-button, .excel-button,
.list-button, .list-button,
.icon-button, .icon-button,
@@ -232,6 +233,12 @@ textarea:focus-visible {
border-color: color-mix(in srgb, var(--error, #ba1a1a) 32%, transparent); border-color: color-mix(in srgb, var(--error, #ba1a1a) 32%, transparent);
} }
.warning-button {
color: var(--on-warning-container, #291800);
background: var(--warning-container, #ffddb0);
border-color: color-mix(in srgb, var(--warning, #8d5700) 32%, transparent);
}
.drawer-nav, .drawer-nav,
.drawer-footer { .drawer-footer {
display: grid; display: grid;
@@ -603,6 +610,95 @@ textarea:focus-visible {
border-color: color-mix(in srgb, var(--warning, #8d5700) 30%, transparent); border-color: color-mix(in srgb, var(--warning, #8d5700) 30%, transparent);
} }
.toast-stack {
position: fixed;
top: calc(12px + env(safe-area-inset-top, 0px));
left: 50%;
transform: translateX(-50%);
z-index: 110;
display: grid;
gap: 8px;
width: min(420px, calc(100vw - 24px));
pointer-events: none;
}
.toast {
display: flex;
align-items: flex-start;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--outline-variant, #d8e1ee);
border-radius: 16px;
background: var(--surface-container-lowest, #ffffff);
color: var(--text, #202124);
box-shadow: var(--elevation-2, 0 12px 28px rgba(60, 64, 67, 0.14));
font-size: 14px;
pointer-events: auto;
}
.toast-icon {
flex: 0 0 auto;
margin-top: 1px;
}
.toast--success {
border-color: color-mix(in srgb, var(--success, #0d652d) 35%, transparent);
}
.toast--success .toast-icon {
color: var(--success, #0d652d);
}
.toast--error {
border-color: color-mix(in srgb, var(--error, #ba1a1a) 35%, transparent);
}
.toast--error .toast-icon {
color: var(--error, #ba1a1a);
}
.toast--info {
border-color: color-mix(in srgb, var(--primary, #2563c7) 35%, transparent);
}
.toast--info .toast-icon {
color: var(--primary, #2563c7);
}
.toast-message {
flex: 1 1 auto;
min-width: 0;
line-height: 1.45;
}
.toast-dismiss {
display: inline-flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
margin: -4px -6px -4px 0;
border-radius: 999px;
color: var(--muted, #68747c);
transition: background 0.16s ease, color 0.16s ease;
}
.toast-dismiss:hover {
background: color-mix(in srgb, var(--text, #202124) 8%, transparent);
color: var(--text, #202124);
}
@media (min-width: 640px) {
.toast-stack {
top: auto;
left: auto;
right: 20px;
bottom: calc(20px + env(safe-area-inset-bottom, 0px));
transform: none;
}
}
.summary-panel, .summary-panel,
.can-panel { .can-panel {
background: var(--surface-container-lowest, #ffffff); background: var(--surface-container-lowest, #ffffff);
+20
View File
@@ -0,0 +1,20 @@
import { createContext, useContext } from "react";
export type ToastTone = "success" | "error" | "info";
export type ToastInput = {
tone: ToastTone;
message: string;
};
export type ToastContextValue = {
pushToast: (toast: ToastInput) => void;
};
export const ToastContext = createContext<ToastContextValue | null>(null);
export function useToasts(): ToastContextValue {
const context = useContext(ToastContext);
if (!context) throw new Error("useToasts must be used within a ToastProvider.");
return context;
}
+3
View File
@@ -1,10 +1,13 @@
import React from "react"; import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import App from "./App"; import App from "./App";
import { ToastProvider } from "./components/Toasts";
import "./index.css"; import "./index.css";
ReactDOM.createRoot(document.getElementById("root")!).render( ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode> <React.StrictMode>
<ToastProvider>
<App /> <App />
</ToastProvider>
</React.StrictMode>, </React.StrictMode>,
); );