UX overhaul: routing, accessibility, feedback, and polish
Build and push image / build (push) Successful in 50s

Add react-router-dom for URL-based navigation with browser
back/forward, deep links, and bookmarks. Replace window.confirm()
with styled ConfirmDialog. Add toast notifications and success
feedback on consume/audit/gone flows. Add escape-to-close and
focus trapping on modals. Add entrance animations for drawers,
modals, and toasts. Make grids responsive, add sortable inventory
headers, working CSV/JSON export, time-aware greeting, focus-visible
outlines, search clear button, and hover chevrons on inventory rows.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-04 18:54:49 -04:00
parent 80034b47c5
commit a82045d1bd
21 changed files with 640 additions and 145 deletions
+9
View File
@@ -6,6 +6,7 @@ import { api } from "../../api.js";
import { Btn, Field, Input, Select } from "../primitives/index.js";
import { ScanField, type ScanResult } from "../ScanField.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
import { useToast } from "../Toast.js";
const AUDIT_MODE_LABELS: Record<string, { title: string; desc: string }> = {
weigh: {
@@ -32,6 +33,7 @@ export function AuditFlow({
item: Item | null;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const allItems = enrichItems(data);
const overdueFirst = [...allItems]
.filter((i) => i.status === "active")
@@ -52,6 +54,7 @@ export function AuditFlow({
return helpers.estimatedRemaining(i, TODAY_STR).toFixed(2);
};
const [value, setValue] = useState<string>(initialValueFor(item));
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setValue(initialValueFor(item));
@@ -67,8 +70,10 @@ export function AuditFlow({
}),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
toast(`Audit saved — next due in ${cfg?.cadenceDays ?? "?"}d`);
onClose();
},
onError: (e: Error) => setError(e.message),
});
const handleScan = (result: ScanResult) => {
@@ -244,6 +249,10 @@ export function AuditFlow({
</div>
</div>
</div>
{error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
<ModalFooter>
@@ -0,0 +1,62 @@
import { Btn } from "../primitives/index.js";
import { ModalBackdrop, ModalFooter } from "./ModalChrome.js";
export function ConfirmDialog({
title,
message,
confirmLabel = "Delete",
confirmVariant = "danger",
onConfirm,
onCancel,
isPending = false,
}: {
title: string;
message: string;
confirmLabel?: string;
confirmVariant?: "danger" | "primary";
onConfirm: () => void;
onCancel: () => void;
isPending?: boolean;
}) {
return (
<ModalBackdrop onClose={onCancel}>
<div
style={{
width: "min(460px, 96vw)",
margin: "120px 20px",
background: "var(--bg)",
border: "1px solid var(--line)",
borderRadius: "var(--r-lg)",
boxShadow: "var(--shadow-lg)",
}}
>
<div style={{ padding: "28px 32px 8px" }}>
<h2
className="serif"
style={{ fontSize: 24, margin: 0, fontWeight: 500, lineHeight: 1.2 }}
>
{title}
</h2>
<p style={{ fontSize: 13, color: "var(--ink-2)", marginTop: 10, lineHeight: 1.5 }}>
{message}
</p>
</div>
<ModalFooter>
<div />
<div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onCancel}>
Cancel
</Btn>
<Btn
variant={confirmVariant}
disabled={isPending}
onClick={onConfirm}
>
{isPending ? "Deleting…" : confirmLabel}
</Btn>
</div>
</ModalFooter>
</div>
</ModalBackdrop>
);
}
@@ -8,6 +8,7 @@ import { api } from "../../api.js";
import { Btn, Field, Icon, Input, Select, Textarea } from "../primitives/index.js";
import { ScanField, type ScanResult } from "../ScanField.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
import { useToast } from "../Toast.js";
export function ConsumeFlow({
data,
@@ -19,12 +20,14 @@ export function ConsumeFlow({
item: Item | null;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const allItems = enrichItems(data);
const active = allItems.filter((i) => i.status === "active");
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
const [rating, setRating] = useState(4);
const [notes, setNotes] = useState("");
const [date, setDate] = useState(TODAY_STR);
const [error, setError] = useState<string | null>(null);
const item = allItems.find((i) => i.id === itemId);
@@ -32,8 +35,10 @@ export function ConsumeFlow({
mutationFn: () => api.finishInventoryItem(itemId, { date, rating, notes }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
toast(`Marked ${item?.name ?? "item"} as consumed — ${rating}/5 stars`);
onClose();
},
onError: (e: Error) => setError(e.message),
});
const handleScan = (result: ScanResult) => {
@@ -163,6 +168,10 @@ export function ConsumeFlow({
/>
</Field>
</div>
{error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
<ModalFooter>
@@ -6,6 +6,7 @@ import { remainingShort } from "../../stats.js";
import { api } from "../../api.js";
import { Btn, Field, Input, Select, Textarea } from "../primitives/index.js";
import { ModalBackdrop, ModalHeader, ModalFooter } from "./ModalChrome.js";
import { useToast } from "../Toast.js";
const REASONS: [string, string][] = [
["lost", "Lost / misplaced"],
@@ -25,20 +26,24 @@ export function MarkGoneFlow({
item: Item | null;
}) {
const qc = useQueryClient();
const { toast } = useToast();
const allItems = enrichItems(data);
const active = allItems.filter((i) => i.status === "active");
const [itemId, setItemId] = useState(initialItem?.id ?? active[0]?.id ?? "");
const [reason, setReason] = useState("lost");
const [notes, setNotes] = useState("");
const [date, setDate] = useState(TODAY_STR);
const [error, setError] = useState<string | null>(null);
const item = allItems.find((i) => i.id === itemId);
const mark = useMutation({
mutationFn: () => api.markInventoryItemGone(itemId, { date, reason, notes }),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] });
toast(`Marked ${item?.name ?? "item"} as gone`);
onClose();
},
onError: (e: Error) => setError(e.message),
});
if (!item) return null;
@@ -109,6 +114,10 @@ export function MarkGoneFlow({
/>
</Field>
</div>
{error && (
<div style={{ marginTop: 14, fontSize: 12, color: "var(--terracotta)" }}>{error}</div>
)}
</div>
<ModalFooter>
+50 -1
View File
@@ -1,3 +1,4 @@
import { useEffect, useRef } from "react";
import { Btn } from "../primitives/index.js";
export function ModalBackdrop({
@@ -7,8 +8,55 @@ export function ModalBackdrop({
children: React.ReactNode;
onClose: () => void;
}) {
const backdropRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<Element | null>(null);
useEffect(() => {
previousFocus.current = document.activeElement;
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
e.stopPropagation();
onClose();
return;
}
if (e.key === "Tab" && backdropRef.current) {
const focusable = backdropRef.current.querySelectorAll<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
if (focusable.length === 0) return;
const first = focusable[0]!;
const last = focusable[focusable.length - 1]!;
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first.focus();
}
}
};
document.addEventListener("keydown", handleKeyDown);
const firstFocusable = backdropRef.current?.querySelector<HTMLElement>(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
);
firstFocusable?.focus();
return () => {
document.removeEventListener("keydown", handleKeyDown);
if (previousFocus.current instanceof HTMLElement) {
previousFocus.current.focus();
}
};
}, [onClose]);
return (
<div
ref={backdropRef}
role="dialog"
aria-modal="true"
style={{
position: "fixed",
inset: 0,
@@ -18,12 +66,13 @@ export function ModalBackdrop({
justifyContent: "center",
alignItems: "flex-start",
overflow: "auto",
animation: "backdrop-in 200ms ease-out",
}}
onClick={onClose}
>
<div
onClick={(e) => e.stopPropagation()}
style={{ width: "100%", display: "flex", justifyContent: "center" }}
style={{ width: "100%", display: "flex", justifyContent: "center", animation: "modal-in 200ms ease-out" }}
>
{children}
</div>