UX overhaul: routing, accessibility, feedback, and polish
Build and push image / build (push) Successful in 50s
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user