From 00a76a10d7fd0ae1a672304768fc64e583b9f3e1 Mon Sep 17 00:00:00 2001 From: josh Date: Fri, 8 May 2026 10:44:19 -0400 Subject: [PATCH] Fix 15 UX friction points across modals, navigation, and accessibility Addresses bulk operation safety (confirmation for consume/gone), drawer action hierarchy, exit animations, sidebar action distinction, toast dismissibility and ARIA, retry affordance, sort direction toggle, sequential audit queue from dashboard, mobile nav separation, price label disambiguation, grouped-view sort consistency, footer context hints, bin deletion feedback, segmented control ARIA, and drawer focus trapping. Co-Authored-By: Claude Opus 4.6 --- web/src/App.tsx | 12 ++- web/src/components/ProductDetail.tsx | 66 +++++++++++------ web/src/components/Sidebar.tsx | 8 +- web/src/components/Toast.tsx | 52 +++++++++++-- .../components/modals/AddInventoryFlow.tsx | 5 +- web/src/components/modals/AuditFlow.tsx | 27 ++++++- .../components/modals/BulkConsumeModal.tsx | 29 +++++++- web/src/components/modals/BulkGoneModal.tsx | 29 +++++++- web/src/components/modals/ConsumeFlow.tsx | 6 +- web/src/components/modals/ModalChrome.tsx | 36 ++++++--- web/src/hooks/useExitAnimation.ts | 12 +++ web/src/hooks/useFocusTrap.ts | 43 +++++++++++ web/src/styles/global.css | 27 +++++++ web/src/views/BinsView.tsx | 3 + web/src/views/Dashboard.tsx | 6 +- web/src/views/Inventory.tsx | 73 ++++++++++++++----- 16 files changed, 356 insertions(+), 78 deletions(-) create mode 100644 web/src/hooks/useExitAnimation.ts create mode 100644 web/src/hooks/useFocusTrap.ts diff --git a/web/src/App.tsx b/web/src/App.tsx index 97d3f82..72415a0 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -129,8 +129,16 @@ export function App() { setSelected(null); setModal("gone"); }; + const [auditQueue, setAuditQueue] = useState([]); const openAudit = (i?: Item) => { setModalItem(i ?? null); + setAuditQueue([]); + setModal("audit"); + }; + const openAuditQueue = (queue: Item[]) => { + if (queue.length === 0) return; + setModalItem(queue[0]!); + setAuditQueue(queue); setModal("audit"); }; const openCheckout = (i?: Item) => { @@ -217,7 +225,7 @@ export function App() {
+ } /> setModal(null)} item={modalItem} /> )} {modal === "audit" && ( - setModal(null)} item={modalItem} /> + setModal(null)} item={modalItem} queue={auditQueue.length > 0 ? auditQueue : undefined} /> )} {modal === "checkout" && ( setModal(null)} item={modalItem} /> diff --git a/web/src/components/ProductDetail.tsx b/web/src/components/ProductDetail.tsx index 60b269c..7de2139 100644 --- a/web/src/components/ProductDetail.tsx +++ b/web/src/components/ProductDetail.tsx @@ -4,6 +4,8 @@ import { TYPES, helpers } from "../types.js"; import { getToday, getStoredTimezone } from "../tz.js"; import { fmt, TYPE_GLYPHS } from "../format.js"; import { Btn, Pill, Icon } from "./primitives/index.js"; +import { useExitAnimation } from "../hooks/useExitAnimation.js"; +import { useFocusTrap } from "../hooks/useFocusTrap.js"; // Right-side drawer for an inventory instance. Shows the asset id and // product context up top, then per-batch fields (price, THC, weight), @@ -40,14 +42,16 @@ export function ProductDetail({ const isActive = item.status === "active"; const isCheckedOut = item.status === "checked-out"; + const { closing, triggerClose } = useExitAnimation(220, onClose); + const trapRef = useFocusTrap(); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape") onClose(); + if (e.key === "Escape") triggerClose(); }; document.addEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown); - }, [onClose]); + }, [triggerClose]); // Sibling instances of the same product (excluding this one) — useful for // seeing previous purchases of the same SKU. @@ -101,6 +105,9 @@ export function ProductDetail({ return (
e.stopPropagation()} style={{ width: "min(720px, 100vw)", height: "100%", - animation: "drawer-in 250ms ease-out", + animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out", background: "var(--bg)", borderLeft: "1px solid var(--line)", overflow: "auto", @@ -140,32 +147,47 @@ export function ProductDetail({
Inventory · {item.assetId}
-
- {isActive && ( +
+ {isActive && overdue && ( + onAudit(item)}> + Audit + + )} + {isActive && !overdue && ( + onCheckout(item)}> + Check out + + )} + {isCheckedOut && ( + onCheckin(item)}> + Check in + + )} +
+ {isActive && !overdue && ( + onAudit(item)}> + Audit + + )} + {isActive && overdue && ( onCheckout(item)}> Check out )} - {isActive && ( - onAudit(item)}> - Audit - - )} - {isCheckedOut && ( - onCheckin(item)}> - Check in + {(isActive || isCheckedOut) && ( + onConsume(item)}> + Consume )} {(isActive || isCheckedOut) && ( - onConsume(item)}> - Mark consumed + onMarkGone(item)}> + Gone )} - {(isActive || isCheckedOut) && ( - onMarkGone(item)} /> - )} - onEdit(item)} /> - + onEdit(item)}> + Edit + +
diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index 7747fb7..53d1c43 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -88,16 +88,16 @@ export function Sidebar({ ))}
Quick
- - - - diff --git a/web/src/components/Toast.tsx b/web/src/components/Toast.tsx index 9e906fe..773585c 100644 --- a/web/src/components/Toast.tsx +++ b/web/src/components/Toast.tsx @@ -1,4 +1,4 @@ -import { createContext, useCallback, useContext, useState } from "react"; +import { createContext, useCallback, useContext, useRef, useState } from "react"; import { Icon } from "./primitives/index.js"; type ToastType = "success" | "error"; @@ -19,18 +19,44 @@ let nextId = 0; export function ToastProvider({ children }: { children: React.ReactNode }) { const [toasts, setToasts] = useState([]); + const timers = useRef>>(new Map()); + + const dismiss = useCallback((id: number) => { + const timer = timers.current.get(id); + if (timer) clearTimeout(timer); + timers.current.delete(id); + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, []); + + const startTimer = useCallback((id: number) => { + const timer = setTimeout(() => { + timers.current.delete(id); + setToasts((prev) => prev.filter((t) => t.id !== id)); + }, 4000); + timers.current.set(id, timer); + }, []); + + const pauseTimer = useCallback((id: number) => { + const timer = timers.current.get(id); + if (timer) { + clearTimeout(timer); + timers.current.delete(id); + } + }, []); const toast = useCallback((message: string, type: ToastType = "success") => { const id = nextId++; setToasts((prev) => [...prev, { id, message, type }]); - setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); - }, []); + startTimer(id); + }, [startTimer]); return ( {children} {toasts.length > 0 && (
{toasts.map((t) => (
pauseTimer(t.id)} + onMouseLeave={() => startTimer(t.id)} style={{ pointerEvents: "auto", padding: "12px 18px", @@ -66,7 +93,22 @@ export function ToastProvider({ children }: { children: React.ReactNode }) { size={16} color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"} /> - {t.message} + {t.message} +
))}
diff --git a/web/src/components/modals/AddInventoryFlow.tsx b/web/src/components/modals/AddInventoryFlow.tsx index 473a797..1d0d645 100644 --- a/web/src/components/modals/AddInventoryFlow.tsx +++ b/web/src/components/modals/AddInventoryFlow.tsx @@ -607,7 +607,10 @@ function InstanceDetailsStep({ /> )} - + 0 ? `1 × ${fmt.money(form.price)} = ${fmt.money(form.price)} total` : undefined} + > void; item: Item | null; + queue?: Item[]; }) { const qc = useQueryClient(); const { toast } = useToast(); @@ -40,6 +42,7 @@ export function AuditFlow({ .filter((i) => i.status === "active") .sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a)); + const [queueIdx, setQueueIdx] = useState(0); const [itemId, setItemId] = useState(initialItem?.id ?? ""); const [date, setDate] = useState(getToday(getStoredTimezone())); const [confirmedBy, setConfirmedBy] = useState<"asset" | "visual">("asset"); @@ -66,6 +69,7 @@ export function AuditFlow({ setValue(initialValueFor(item)); setInputMode(item?.containerWeight != null ? "container" : "direct"); setContainerTotal(""); + setError(null); }, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps const tare = item ? helpers.tare(item) : null; @@ -92,7 +96,14 @@ export function AuditFlow({ onSuccess: () => { qc.invalidateQueries({ queryKey: ["bootstrap"] }); toast(`Audit saved — next due in ${cfg?.cadenceDays ?? "?"}d`); - onClose(); + if (queue && queueIdx + 1 < queue.length) { + const nextItem = queue[queueIdx + 1]!; + setQueueIdx((i) => i + 1); + setItemId(nextItem.id); + setError(null); + } else { + onClose(); + } }, onError: (e: Error) => setError(e.message), }); @@ -131,7 +142,11 @@ export function AuditFlow({ boxShadow: "var(--shadow-lg)", }} > - + 1 ? `${queueIdx + 1} of ${queue.length} overdue` : ""} + onClose={onClose} + />
audit.mutate()} > - {audit.isPending ? "Saving…" : "Save audit"} + {audit.isPending + ? "Saving…" + : error + ? "Try again" + : queue && queueIdx + 1 < queue.length + ? "Save & next" + : "Save audit"}
diff --git a/web/src/components/modals/BulkConsumeModal.tsx b/web/src/components/modals/BulkConsumeModal.tsx index 990d221..962419c 100644 --- a/web/src/components/modals/BulkConsumeModal.tsx +++ b/web/src/components/modals/BulkConsumeModal.tsx @@ -123,8 +123,29 @@ export function BulkConsumeModal({
-
- Items: {eligible.map((i) => i.name).join(", ")} +
+
+ Items to consume +
+
+ {eligible.map((i) => ( +
+ {i.name} + {i.assetId} +
+ ))} +
+
+ This cannot be undone. +
)} @@ -135,7 +156,9 @@ export function BulkConsumeModal({
-
+
+ {eligible.length} item{eligible.length === 1 ? "" : "s"} will be permanently archived. +
Cancel
-
- Items: {eligible.map((i) => i.name).join(", ")} +
+
+ Items to mark gone +
+
+ {eligible.map((i) => ( +
+ {i.name} + {i.assetId} +
+ ))} +
+
+ This cannot be undone. +
)} @@ -143,7 +164,9 @@ export function BulkGoneModal({
-
+
+ {eligible.length} item{eligible.length === 1 ? "" : "s"} will be permanently archived. +
Cancel -
+
+ {item ? `Lasted ${lifespan} day${lifespan === 1 ? "" : "s"} from purchase.` : ""} +
Cancel finish.mutate()} > - {finish.isPending ? "Saving…" : "Mark consumed"} + {finish.isPending ? "Saving…" : error ? "Try again" : "Mark consumed"}
diff --git a/web/src/components/modals/ModalChrome.tsx b/web/src/components/modals/ModalChrome.tsx index 50025a1..ce4db29 100644 --- a/web/src/components/modals/ModalChrome.tsx +++ b/web/src/components/modals/ModalChrome.tsx @@ -1,6 +1,9 @@ -import { useEffect, useRef } from "react"; +import { createContext, useContext, useEffect, useRef } from "react"; +import { useExitAnimation } from "../../hooks/useExitAnimation.js"; import { Btn } from "../primitives/index.js"; +const ModalCloseCtx = createContext<(() => void) | null>(null); + export function ModalBackdrop({ children, onClose, @@ -10,6 +13,7 @@ export function ModalBackdrop({ }) { const backdropRef = useRef(null); const previousFocus = useRef(null); + const { closing, triggerClose } = useExitAnimation(180, onClose); useEffect(() => { previousFocus.current = document.activeElement; @@ -17,7 +21,7 @@ export function ModalBackdrop({ const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape") { e.stopPropagation(); - onClose(); + triggerClose(); return; } if (e.key === "Tab" && backdropRef.current) { @@ -50,7 +54,7 @@ export function ModalBackdrop({ previousFocus.current.focus(); } }; - }, [onClose]); + }, [triggerClose]); return (
-
e.stopPropagation()} - style={{ width: "100%", display: "flex", justifyContent: "center", animation: "modal-in 200ms ease-out" }} - > - {children} -
+ +
e.stopPropagation()} + style={{ + width: "100%", + display: "flex", + justifyContent: "center", + animation: closing ? "modal-out 180ms ease-in forwards" : "modal-in 200ms ease-out", + }} + > + {children} +
+
); } @@ -91,6 +102,7 @@ export function ModalHeader({ eyebrowColor?: string; onClose: () => void; }) { + const animatedClose = useContext(ModalCloseCtx); return (
- +
); } diff --git a/web/src/hooks/useExitAnimation.ts b/web/src/hooks/useExitAnimation.ts new file mode 100644 index 0000000..630f3da --- /dev/null +++ b/web/src/hooks/useExitAnimation.ts @@ -0,0 +1,12 @@ +import { useCallback, useState } from "react"; + +export function useExitAnimation(durationMs: number, onDone: () => void) { + const [closing, setClosing] = useState(false); + + const triggerClose = useCallback(() => { + setClosing(true); + setTimeout(onDone, durationMs); + }, [durationMs, onDone]); + + return { closing, triggerClose }; +} diff --git a/web/src/hooks/useFocusTrap.ts b/web/src/hooks/useFocusTrap.ts new file mode 100644 index 0000000..626c4da --- /dev/null +++ b/web/src/hooks/useFocusTrap.ts @@ -0,0 +1,43 @@ +import { useEffect, useRef } from "react"; + +const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'; + +export function useFocusTrap() { + const ref = useRef(null); + const previousFocus = useRef(null); + + useEffect(() => { + previousFocus.current = document.activeElement; + + const el = ref.current; + if (!el) return; + + const firstFocusable = el.querySelector(FOCUSABLE); + firstFocusable?.focus(); + + const handleTab = (e: KeyboardEvent) => { + if (e.key !== "Tab" || !el) return; + const focusable = el.querySelectorAll(FOCUSABLE); + 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", handleTab); + return () => { + document.removeEventListener("keydown", handleTab); + if (previousFocus.current instanceof HTMLElement) { + previousFocus.current.focus(); + } + }; + }, []); + + return ref; +} diff --git a/web/src/styles/global.css b/web/src/styles/global.css index d0b9da7..45a9442 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -69,6 +69,16 @@ padding: 16px 12px 6px; font-weight: 500; } +.nav-action { + border: 1px dashed var(--line-strong); + color: var(--ink-2); + font-size: 13px; +} +.nav-action:hover { + border-style: solid; + background: var(--sage-soft); + color: var(--ink); +} .nav-divider { display: none; } @@ -109,6 +119,18 @@ from { transform: translateY(100%); opacity: 0; } to { transform: translateY(0); opacity: 1; } } +@keyframes backdrop-out { + from { opacity: 1; } + to { opacity: 0; } +} +@keyframes modal-out { + from { opacity: 1; transform: scale(1) translateY(0); } + to { opacity: 0; transform: scale(0.97) translateY(8px); } +} +@keyframes drawer-out { + from { transform: translateX(0); } + to { transform: translateX(100%); } +} @media (max-width: 1200px) { .inv-row > :nth-child(5), @@ -151,12 +173,17 @@ display: none; } .nav-divider { + display: block; width: 1px; height: 24px; background: var(--line); flex-shrink: 0; align-self: center; } + .nav-action { + border-style: solid; + border-color: var(--line); + } .main { padding-bottom: 60px; } diff --git a/web/src/views/BinsView.tsx b/web/src/views/BinsView.tsx index ccffec1..2a9510d 100644 --- a/web/src/views/BinsView.tsx +++ b/web/src/views/BinsView.tsx @@ -8,6 +8,7 @@ import { fmt, TYPE_GLYPHS } from "../format.js"; import { api } from "../api.js"; import { Btn, Card, Pill, Icon } from "../components/primitives/index.js"; import { ConfirmDialog } from "../components/modals/ConfirmDialog.js"; +import { useToast } from "../components/Toast.js"; // Bins follow a "letter + number" naming convention (A1, A2, B1, …). // Group by the letter prefix so each letter starts a new visual row, @@ -51,6 +52,7 @@ export function BinsView({ onEditBin: (bin: Bin) => void; }) { const qc = useQueryClient(); + const { toast } = useToast(); const items = useMemo(() => enrichItems(data), [data]); const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null); @@ -58,6 +60,7 @@ export function BinsView({ const remove = useMutation({ mutationFn: (id: string) => api.deleteBin(id), onSuccess: () => { + toast(`Deleted bin "${confirmDelete?.name ?? ""}"`); qc.invalidateQueries({ queryKey: ["bootstrap"] }); setConfirmDelete(null); }, diff --git a/web/src/views/Dashboard.tsx b/web/src/views/Dashboard.tsx index 16fc849..d57b340 100644 --- a/web/src/views/Dashboard.tsx +++ b/web/src/views/Dashboard.tsx @@ -20,11 +20,13 @@ export function Dashboard({ data, stats, onAuditItem, + onAuditQueue, onSelectItem, }: { data: Bootstrap; stats: Stats; onAuditItem: (i: Item) => void; + onAuditQueue: (items: Item[]) => void; onSelectItem: (i: Item) => void; }) { const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" })); @@ -174,8 +176,8 @@ export function Dashboard({ {overdue.length > 3 && ` · +${overdue.length - 3} more`}
- onAuditItem(overdue[0]!)}> - Run audit + onAuditQueue(overdue)}> + Audit {overdue.length > 1 ? `all ${overdue.length}` : ""}
diff --git a/web/src/views/Inventory.tsx b/web/src/views/Inventory.tsx index 5ac7a62..2c2392c 100644 --- a/web/src/views/Inventory.tsx +++ b/web/src/views/Inventory.tsx @@ -40,6 +40,7 @@ export function Inventory({ const [filter, setFilter] = useState("active"); const [typeFilter, setTypeFilter] = useState("all"); const [sortBy, setSortBy] = useState("recent"); + const [sortAsc, setSortAsc] = useState(false); const [search, setSearch] = useState(""); const [view, setView] = useState( () => (localStorage.getItem("apothecary.inventoryView") as ViewKey | null) ?? "flat", @@ -49,14 +50,15 @@ export function Inventory({ }, [view]); const sortFn = (a: Item, b: Item) => { - if (sortBy === "recent") return +new Date(b.purchaseDate) - +new Date(a.purchaseDate); - if (sortBy === "name") return a.name.localeCompare(b.name); - if (sortBy === "thc") return b.thc - a.thc; + const dir = sortAsc ? -1 : 1; + if (sortBy === "recent") return dir * (+new Date(b.purchaseDate) - +new Date(a.purchaseDate)); + if (sortBy === "name") return dir * a.name.localeCompare(b.name); + if (sortBy === "thc") return dir * (b.thc - a.thc); if (sortBy === "remaining") - return helpers.estimatedRemaining(b, getToday(getStoredTimezone())) - helpers.estimatedRemaining(a, getToday(getStoredTimezone())); - if (sortBy === "price") return b.price - a.price; + return dir * (helpers.estimatedRemaining(b, getToday(getStoredTimezone())) - helpers.estimatedRemaining(a, getToday(getStoredTimezone()))); + if (sortBy === "price") return dir * (b.price - a.price); if (sortBy === "audit") - return helpers.daysSinceCheck(b, getToday(getStoredTimezone())) - helpers.daysSinceCheck(a, getToday(getStoredTimezone())); + return dir * (helpers.daysSinceCheck(b, getToday(getStoredTimezone())) - helpers.daysSinceCheck(a, getToday(getStoredTimezone()))); return 0; }; @@ -84,7 +86,7 @@ export function Inventory({ return out; }, [items, data, filter, typeFilter, search]); - const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy]); + const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy, sortAsc]); type Group = { productId: string; @@ -112,12 +114,11 @@ export function Inventory({ }); } out.sort((a, b) => { - const aMax = Math.max(...a.items.map((p) => +new Date(p.purchaseDate))); - const bMax = Math.max(...b.items.map((p) => +new Date(p.purchaseDate))); - return bMax - aMax; + if (a.items.length === 0 || b.items.length === 0) return 0; + return sortFn(a.items[0]!, b.items[0]!); }); return out; - }, [filtered, sortBy]); + }, [filtered, sortBy, sortAsc]); // All visible item IDs in display order (flat or grouped) const visibleIds = useMemo(() => { @@ -255,15 +256,23 @@ export function Inventory({
@@ -271,7 +280,15 @@ export function Inventory({ { + if (key === sortBy) { + setSortAsc((prev) => !prev); + } else { + setSortBy(key); + setSortAsc(false); + } + }} isAllSelected={isAllSelected} isIndeterminate={isIndeterminate} onToggleAll={toggleAll} @@ -346,8 +363,20 @@ function Segmented({ options: [T, string][]; onChange: (v: T) => void; }) { + const handleKeyDown = (e: React.KeyboardEvent, idx: number) => { + let next = -1; + if (e.key === "ArrowRight" || e.key === "ArrowDown") next = (idx + 1) % options.length; + if (e.key === "ArrowLeft" || e.key === "ArrowUp") next = (idx - 1 + options.length) % options.length; + if (next >= 0) { + e.preventDefault(); + onChange(options[next]![0]); + (e.currentTarget.parentElement?.children[next] as HTMLElement)?.focus(); + } + }; + return (
({ padding: 3, }} > - {options.map(([k, l]) => ( + {options.map(([k, l], i) => ( ); })}