Fix 15 UX friction points across modals, navigation, and accessibility
Build and push image / build (push) Successful in 49s

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 <noreply@anthropic.com>
This commit is contained in:
2026-05-08 10:44:19 -04:00
parent 3bdf857099
commit 00a76a10d7
16 changed files with 356 additions and 78 deletions
+10 -2
View File
@@ -129,8 +129,16 @@ export function App() {
setSelected(null); setSelected(null);
setModal("gone"); setModal("gone");
}; };
const [auditQueue, setAuditQueue] = useState<Item[]>([]);
const openAudit = (i?: Item) => { const openAudit = (i?: Item) => {
setModalItem(i ?? null); setModalItem(i ?? null);
setAuditQueue([]);
setModal("audit");
};
const openAuditQueue = (queue: Item[]) => {
if (queue.length === 0) return;
setModalItem(queue[0]!);
setAuditQueue(queue);
setModal("audit"); setModal("audit");
}; };
const openCheckout = (i?: Item) => { const openCheckout = (i?: Item) => {
@@ -217,7 +225,7 @@ export function App() {
<main className="main parchment" style={{ minWidth: 0 }}> <main className="main parchment" style={{ minWidth: 0 }}>
<Routes> <Routes>
<Route path="/" element={ <Route path="/" element={
<Dashboard data={data} stats={stats} onAuditItem={openAudit} onSelectItem={setSelected} /> <Dashboard data={data} stats={stats} onAuditItem={openAudit} onAuditQueue={openAuditQueue} onSelectItem={setSelected} />
} /> } />
<Route path="/inventory" element={ <Route path="/inventory" element={
<Inventory <Inventory
@@ -305,7 +313,7 @@ export function App() {
<MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} /> <MarkGoneFlow data={data} onClose={() => setModal(null)} item={modalItem} />
)} )}
{modal === "audit" && ( {modal === "audit" && (
<AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} /> <AuditFlow data={data} onClose={() => setModal(null)} item={modalItem} queue={auditQueue.length > 0 ? auditQueue : undefined} />
)} )}
{modal === "checkout" && ( {modal === "checkout" && (
<CheckoutFlow data={data} onClose={() => setModal(null)} item={modalItem} /> <CheckoutFlow data={data} onClose={() => setModal(null)} item={modalItem} />
+44 -22
View File
@@ -4,6 +4,8 @@ import { TYPES, helpers } from "../types.js";
import { getToday, getStoredTimezone } from "../tz.js"; import { getToday, getStoredTimezone } from "../tz.js";
import { fmt, TYPE_GLYPHS } from "../format.js"; import { fmt, TYPE_GLYPHS } from "../format.js";
import { Btn, Pill, Icon } from "./primitives/index.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 // Right-side drawer for an inventory instance. Shows the asset id and
// product context up top, then per-batch fields (price, THC, weight), // product context up top, then per-batch fields (price, THC, weight),
@@ -40,14 +42,16 @@ export function ProductDetail({
const isActive = item.status === "active"; const isActive = item.status === "active";
const isCheckedOut = item.status === "checked-out"; const isCheckedOut = item.status === "checked-out";
const { closing, triggerClose } = useExitAnimation(220, onClose);
const trapRef = useFocusTrap<HTMLDivElement>();
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose(); if (e.key === "Escape") triggerClose();
}; };
document.addEventListener("keydown", handleKeyDown); document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown); return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]); }, [triggerClose]);
// Sibling instances of the same product (excluding this one) — useful for // Sibling instances of the same product (excluding this one) — useful for
// seeing previous purchases of the same SKU. // seeing previous purchases of the same SKU.
@@ -101,6 +105,9 @@ export function ProductDetail({
return ( return (
<div <div
ref={trapRef}
role="dialog"
aria-modal="true"
style={{ style={{
position: "fixed", position: "fixed",
inset: 0, inset: 0,
@@ -108,16 +115,16 @@ export function ProductDetail({
zIndex: 50, zIndex: 50,
display: "flex", display: "flex",
justifyContent: "flex-end", justifyContent: "flex-end",
animation: "backdrop-in 200ms ease-out", animation: closing ? "backdrop-out 220ms ease-in forwards" : "backdrop-in 200ms ease-out",
}} }}
onClick={onClose} onClick={triggerClose}
> >
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ style={{
width: "min(720px, 100vw)", width: "min(720px, 100vw)",
height: "100%", height: "100%",
animation: "drawer-in 250ms ease-out", animation: closing ? "drawer-out 220ms ease-in forwards" : "drawer-in 250ms ease-out",
background: "var(--bg)", background: "var(--bg)",
borderLeft: "1px solid var(--line)", borderLeft: "1px solid var(--line)",
overflow: "auto", overflow: "auto",
@@ -140,32 +147,47 @@ export function ProductDetail({
<div className="smallcaps" style={{ color: "var(--ink-3)" }}> <div className="smallcaps" style={{ color: "var(--ink-3)" }}>
Inventory · <span className="mono">{item.assetId}</span> Inventory · <span className="mono">{item.assetId}</span>
</div> </div>
<div style={{ display: "flex", gap: 6, flexWrap: "wrap", justifyContent: "flex-end" }}> <div style={{ display: "flex", gap: 6, alignItems: "center" }}>
{isActive && ( {isActive && overdue && (
<Btn variant="sage" icon="search" onClick={() => onAudit(item)}>
Audit
</Btn>
)}
{isActive && !overdue && (
<Btn variant="secondary" icon="pocket" onClick={() => onCheckout(item)}>
Check out
</Btn>
)}
{isCheckedOut && (
<Btn variant="sage" icon="pocket" onClick={() => onCheckin(item)}>
Check in
</Btn>
)}
<div style={{ width: 1, height: 20, background: "var(--line)", margin: "0 2px" }} />
{isActive && !overdue && (
<Btn variant="ghost" icon="search" onClick={() => onAudit(item)}>
Audit
</Btn>
)}
{isActive && overdue && (
<Btn variant="ghost" icon="pocket" onClick={() => onCheckout(item)}> <Btn variant="ghost" icon="pocket" onClick={() => onCheckout(item)}>
Check out Check out
</Btn> </Btn>
)} )}
{isActive && ( {(isActive || isCheckedOut) && (
<Btn variant="ghost" icon="check" onClick={() => onAudit(item)}> <Btn variant="ghost" icon="leaf" onClick={() => onConsume(item)}>
Audit Consume
</Btn>
)}
{isCheckedOut && (
<Btn variant="sage" icon="check" onClick={() => onCheckin(item)}>
Check in
</Btn> </Btn>
)} )}
{(isActive || isCheckedOut) && ( {(isActive || isCheckedOut) && (
<Btn variant="secondary" icon="check" onClick={() => onConsume(item)}> <Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)}>
Mark consumed Gone
</Btn> </Btn>
)} )}
{(isActive || isCheckedOut) && ( <Btn variant="ghost" icon="edit" onClick={() => onEdit(item)}>
<Btn variant="ghost" icon="bin" onClick={() => onMarkGone(item)} /> Edit
)} </Btn>
<Btn variant="ghost" icon="edit" onClick={() => onEdit(item)} /> <Btn variant="ghost" icon="close" onClick={triggerClose} />
<Btn variant="ghost" icon="close" onClick={onClose} />
</div> </div>
</div> </div>
+4 -4
View File
@@ -88,16 +88,16 @@ export function Sidebar({
))} ))}
<div className="nav-section">Quick</div> <div className="nav-section">Quick</div>
<div className="nav-divider" /> <div className="nav-divider" />
<button className="nav-link" onClick={onAddProduct} title="Add product"> <button className="nav-link nav-action" onClick={onAddProduct} title="Add product">
<Icon name="plus" size={16} /> <span className="nav-label">Add product</span> <Icon name="plus" size={16} /> <span className="nav-label">Add product</span>
</button> </button>
<button className="nav-link" onClick={onAudit} title="Audit"> <button className="nav-link nav-action" onClick={onAudit} title="Audit">
<Icon name="search" size={16} /> <span className="nav-label">Audit</span> <Icon name="search" size={16} /> <span className="nav-label">Audit</span>
</button> </button>
<button className="nav-link" onClick={onCheckout} title="Check out"> <button className="nav-link nav-action" onClick={onCheckout} title="Check out">
<Icon name="pocket" size={16} /> <span className="nav-label">Check out</span> <Icon name="pocket" size={16} /> <span className="nav-label">Check out</span>
</button> </button>
<button className="nav-link" onClick={onMarkFinished} title="Mark consumed"> <button className="nav-link nav-action" onClick={onMarkFinished} title="Mark consumed">
<Icon name="check" size={16} /> <span className="nav-label">Mark consumed</span> <Icon name="check" size={16} /> <span className="nav-label">Mark consumed</span>
</button> </button>
</aside> </aside>
+47 -5
View File
@@ -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"; import { Icon } from "./primitives/index.js";
type ToastType = "success" | "error"; type ToastType = "success" | "error";
@@ -19,18 +19,44 @@ let nextId = 0;
export function ToastProvider({ children }: { children: React.ReactNode }) { export function ToastProvider({ children }: { children: React.ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]); const [toasts, setToasts] = useState<Toast[]>([]);
const timers = useRef<Map<number, ReturnType<typeof setTimeout>>>(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 toast = useCallback((message: string, type: ToastType = "success") => {
const id = nextId++; const id = nextId++;
setToasts((prev) => [...prev, { id, message, type }]); setToasts((prev) => [...prev, { id, message, type }]);
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 4000); startTimer(id);
}, []); }, [startTimer]);
return ( return (
<ToastContext.Provider value={{ toast }}> <ToastContext.Provider value={{ toast }}>
{children} {children}
{toasts.length > 0 && ( {toasts.length > 0 && (
<div <div
role="status"
aria-live="polite"
style={{ style={{
position: "fixed", position: "fixed",
bottom: 24, bottom: 24,
@@ -39,12 +65,13 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
gap: 8, gap: 8,
pointerEvents: "none",
}} }}
> >
{toasts.map((t) => ( {toasts.map((t) => (
<div <div
key={t.id} key={t.id}
onMouseEnter={() => pauseTimer(t.id)}
onMouseLeave={() => startTimer(t.id)}
style={{ style={{
pointerEvents: "auto", pointerEvents: "auto",
padding: "12px 18px", padding: "12px 18px",
@@ -66,7 +93,22 @@ export function ToastProvider({ children }: { children: React.ReactNode }) {
size={16} size={16}
color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"} color={t.type === "error" ? "var(--terracotta)" : "var(--sage)"}
/> />
{t.message} <span style={{ flex: 1 }}>{t.message}</span>
<button
onClick={() => dismiss(t.id)}
aria-label="Dismiss notification"
style={{
background: "transparent",
border: "none",
cursor: "pointer",
padding: 2,
display: "inline-flex",
color: "var(--ink-3)",
flexShrink: 0,
}}
>
<Icon name="close" size={12} />
</button>
</div> </div>
))} ))}
</div> </div>
@@ -607,7 +607,10 @@ function InstanceDetailsStep({
/> />
</Field> </Field>
)} )}
<Field label="Price ($)"> <Field
label={isDiscrete ? "Price per unit ($)" : "Total price ($)"}
hint={isDiscrete && form.price > 0 ? `1 × ${fmt.money(form.price)} = ${fmt.money(form.price)} total` : undefined}
>
<Input <Input
type="number" type="number"
step="0.01" step="0.01"
+23 -2
View File
@@ -28,10 +28,12 @@ export function AuditFlow({
data, data,
onClose, onClose,
item: initialItem, item: initialItem,
queue,
}: { }: {
data: Bootstrap; data: Bootstrap;
onClose: () => void; onClose: () => void;
item: Item | null; item: Item | null;
queue?: Item[];
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { toast } = useToast(); const { toast } = useToast();
@@ -40,6 +42,7 @@ export function AuditFlow({
.filter((i) => i.status === "active") .filter((i) => i.status === "active")
.sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a)); .sort((a, b) => helpers.daysSinceCheck(b) - helpers.daysSinceCheck(a));
const [queueIdx, setQueueIdx] = useState(0);
const [itemId, setItemId] = useState(initialItem?.id ?? ""); const [itemId, setItemId] = useState(initialItem?.id ?? "");
const [date, setDate] = useState(getToday(getStoredTimezone())); const [date, setDate] = useState(getToday(getStoredTimezone()));
const [confirmedBy, setConfirmedBy] = useState<"asset" | "visual">("asset"); const [confirmedBy, setConfirmedBy] = useState<"asset" | "visual">("asset");
@@ -66,6 +69,7 @@ export function AuditFlow({
setValue(initialValueFor(item)); setValue(initialValueFor(item));
setInputMode(item?.containerWeight != null ? "container" : "direct"); setInputMode(item?.containerWeight != null ? "container" : "direct");
setContainerTotal(""); setContainerTotal("");
setError(null);
}, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps }, [itemId]); // eslint-disable-line react-hooks/exhaustive-deps
const tare = item ? helpers.tare(item) : null; const tare = item ? helpers.tare(item) : null;
@@ -92,7 +96,14 @@ export function AuditFlow({
onSuccess: () => { onSuccess: () => {
qc.invalidateQueries({ queryKey: ["bootstrap"] }); qc.invalidateQueries({ queryKey: ["bootstrap"] });
toast(`Audit saved — next due in ${cfg?.cadenceDays ?? "?"}d`); toast(`Audit saved — next due in ${cfg?.cadenceDays ?? "?"}d`);
if (queue && queueIdx + 1 < queue.length) {
const nextItem = queue[queueIdx + 1]!;
setQueueIdx((i) => i + 1);
setItemId(nextItem.id);
setError(null);
} else {
onClose(); onClose();
}
}, },
onError: (e: Error) => setError(e.message), onError: (e: Error) => setError(e.message),
}); });
@@ -131,7 +142,11 @@ export function AuditFlow({
boxShadow: "var(--shadow-lg)", boxShadow: "var(--shadow-lg)",
}} }}
> >
<ModalHeader title={item ? ml.title : "Audit"} eyebrow="" onClose={onClose} /> <ModalHeader
title={item ? ml.title : "Audit"}
eyebrow={queue && queue.length > 1 ? `${queueIdx + 1} of ${queue.length} overdue` : ""}
onClose={onClose}
/>
<div style={{ padding: 32 }}> <div style={{ padding: 32 }}>
<ScanField <ScanField
@@ -314,7 +329,13 @@ export function AuditFlow({
disabled={audit.isPending || !item} disabled={audit.isPending || !item}
onClick={() => audit.mutate()} onClick={() => audit.mutate()}
> >
{audit.isPending ? "Saving…" : "Save audit"} {audit.isPending
? "Saving…"
: error
? "Try again"
: queue && queueIdx + 1 < queue.length
? "Save & next"
: "Save audit"}
</Btn> </Btn>
</div> </div>
</ModalFooter> </ModalFooter>
+26 -3
View File
@@ -123,8 +123,29 @@ export function BulkConsumeModal({
</Field> </Field>
</div> </div>
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}> <div
Items: {eligible.map((i) => i.name).join(", ")} style={{
marginTop: 20,
padding: 14,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 8 }}>
Items to consume
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{eligible.map((i) => (
<div key={i.id} style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontWeight: 500 }}>{i.name}</span>
<span className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>{i.assetId}</span>
</div>
))}
</div>
<div style={{ marginTop: 10, fontSize: 11, color: "var(--terracotta)", fontWeight: 500 }}>
This cannot be undone.
</div>
</div> </div>
</> </>
)} )}
@@ -135,7 +156,9 @@ export function BulkConsumeModal({
</div> </div>
<ModalFooter> <ModalFooter>
<div /> <div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{eligible.length} item{eligible.length === 1 ? "" : "s"} will be permanently archived.
</div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn> <Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn <Btn
+26 -3
View File
@@ -131,8 +131,29 @@ export function BulkGoneModal({
/> />
</Field> </Field>
</div> </div>
<div style={{ marginTop: 16, fontSize: 12, color: "var(--ink-3)" }}> <div
Items: {eligible.map((i) => i.name).join(", ")} style={{
marginTop: 20,
padding: 14,
background: "var(--bg-2)",
border: "1px solid var(--line)",
borderRadius: "var(--r-md)",
}}
>
<div className="smallcaps" style={{ color: "var(--ink-3)", marginBottom: 8 }}>
Items to mark gone
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{eligible.map((i) => (
<div key={i.id} style={{ fontSize: 13, display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontWeight: 500 }}>{i.name}</span>
<span className="mono" style={{ fontSize: 11, color: "var(--ink-3)" }}>{i.assetId}</span>
</div>
))}
</div>
<div style={{ marginTop: 10, fontSize: 11, color: "var(--terracotta)", fontWeight: 500 }}>
This cannot be undone.
</div>
</div> </div>
</> </>
)} )}
@@ -143,7 +164,9 @@ export function BulkGoneModal({
</div> </div>
<ModalFooter> <ModalFooter>
<div /> <div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{eligible.length} item{eligible.length === 1 ? "" : "s"} will be permanently archived.
</div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn> <Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn <Btn
+4 -2
View File
@@ -166,7 +166,9 @@ export function ConsumeFlow({
</div> </div>
<ModalFooter> <ModalFooter>
<div /> <div style={{ fontSize: 12, color: "var(--ink-3)" }}>
{item ? `Lasted ${lifespan} day${lifespan === 1 ? "" : "s"} from purchase.` : ""}
</div>
<div style={{ display: "flex", gap: 8 }}> <div style={{ display: "flex", gap: 8 }}>
<Btn variant="ghost" onClick={onClose}>Cancel</Btn> <Btn variant="ghost" onClick={onClose}>Cancel</Btn>
<Btn <Btn
@@ -175,7 +177,7 @@ export function ConsumeFlow({
disabled={finish.isPending || !item} disabled={finish.isPending || !item}
onClick={() => finish.mutate()} onClick={() => finish.mutate()}
> >
{finish.isPending ? "Saving…" : "Mark consumed"} {finish.isPending ? "Saving…" : error ? "Try again" : "Mark consumed"}
</Btn> </Btn>
</div> </div>
</ModalFooter> </ModalFooter>
+19 -7
View File
@@ -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"; import { Btn } from "../primitives/index.js";
const ModalCloseCtx = createContext<(() => void) | null>(null);
export function ModalBackdrop({ export function ModalBackdrop({
children, children,
onClose, onClose,
@@ -10,6 +13,7 @@ export function ModalBackdrop({
}) { }) {
const backdropRef = useRef<HTMLDivElement>(null); const backdropRef = useRef<HTMLDivElement>(null);
const previousFocus = useRef<Element | null>(null); const previousFocus = useRef<Element | null>(null);
const { closing, triggerClose } = useExitAnimation(180, onClose);
useEffect(() => { useEffect(() => {
previousFocus.current = document.activeElement; previousFocus.current = document.activeElement;
@@ -17,7 +21,7 @@ export function ModalBackdrop({
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") { if (e.key === "Escape") {
e.stopPropagation(); e.stopPropagation();
onClose(); triggerClose();
return; return;
} }
if (e.key === "Tab" && backdropRef.current) { if (e.key === "Tab" && backdropRef.current) {
@@ -50,7 +54,7 @@ export function ModalBackdrop({
previousFocus.current.focus(); previousFocus.current.focus();
} }
}; };
}, [onClose]); }, [triggerClose]);
return ( return (
<div <div
@@ -66,16 +70,23 @@ export function ModalBackdrop({
justifyContent: "center", justifyContent: "center",
alignItems: "flex-start", alignItems: "flex-start",
overflow: "auto", overflow: "auto",
animation: "backdrop-in 200ms ease-out", animation: closing ? "backdrop-out 180ms ease-in forwards" : "backdrop-in 200ms ease-out",
}} }}
onClick={onClose} onClick={triggerClose}
> >
<ModalCloseCtx.Provider value={triggerClose}>
<div <div
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
style={{ width: "100%", display: "flex", justifyContent: "center", animation: "modal-in 200ms ease-out" }} style={{
width: "100%",
display: "flex",
justifyContent: "center",
animation: closing ? "modal-out 180ms ease-in forwards" : "modal-in 200ms ease-out",
}}
> >
{children} {children}
</div> </div>
</ModalCloseCtx.Provider>
</div> </div>
); );
} }
@@ -91,6 +102,7 @@ export function ModalHeader({
eyebrowColor?: string; eyebrowColor?: string;
onClose: () => void; onClose: () => void;
}) { }) {
const animatedClose = useContext(ModalCloseCtx);
return ( return (
<div <div
style={{ style={{
@@ -111,7 +123,7 @@ export function ModalHeader({
{title} {title}
</h2> </h2>
</div> </div>
<Btn variant="ghost" icon="close" onClick={onClose} /> <Btn variant="ghost" icon="close" onClick={animatedClose ?? onClose} />
</div> </div>
); );
} }
+12
View File
@@ -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 };
}
+43
View File
@@ -0,0 +1,43 @@
import { useEffect, useRef } from "react";
const FOCUSABLE = 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])';
export function useFocusTrap<T extends HTMLElement>() {
const ref = useRef<T>(null);
const previousFocus = useRef<Element | null>(null);
useEffect(() => {
previousFocus.current = document.activeElement;
const el = ref.current;
if (!el) return;
const firstFocusable = el.querySelector<HTMLElement>(FOCUSABLE);
firstFocusable?.focus();
const handleTab = (e: KeyboardEvent) => {
if (e.key !== "Tab" || !el) return;
const focusable = el.querySelectorAll<HTMLElement>(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;
}
+27
View File
@@ -69,6 +69,16 @@
padding: 16px 12px 6px; padding: 16px 12px 6px;
font-weight: 500; 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 { .nav-divider {
display: none; display: none;
} }
@@ -109,6 +119,18 @@
from { transform: translateY(100%); opacity: 0; } from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; } 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) { @media (max-width: 1200px) {
.inv-row > :nth-child(5), .inv-row > :nth-child(5),
@@ -151,12 +173,17 @@
display: none; display: none;
} }
.nav-divider { .nav-divider {
display: block;
width: 1px; width: 1px;
height: 24px; height: 24px;
background: var(--line); background: var(--line);
flex-shrink: 0; flex-shrink: 0;
align-self: center; align-self: center;
} }
.nav-action {
border-style: solid;
border-color: var(--line);
}
.main { .main {
padding-bottom: 60px; padding-bottom: 60px;
} }
+3
View File
@@ -8,6 +8,7 @@ import { fmt, TYPE_GLYPHS } from "../format.js";
import { api } from "../api.js"; import { api } from "../api.js";
import { Btn, Card, Pill, Icon } from "../components/primitives/index.js"; import { Btn, Card, Pill, Icon } from "../components/primitives/index.js";
import { ConfirmDialog } from "../components/modals/ConfirmDialog.js"; import { ConfirmDialog } from "../components/modals/ConfirmDialog.js";
import { useToast } from "../components/Toast.js";
// Bins follow a "letter + number" naming convention (A1, A2, B1, …). // Bins follow a "letter + number" naming convention (A1, A2, B1, …).
// Group by the letter prefix so each letter starts a new visual row, // Group by the letter prefix so each letter starts a new visual row,
@@ -51,6 +52,7 @@ export function BinsView({
onEditBin: (bin: Bin) => void; onEditBin: (bin: Bin) => void;
}) { }) {
const qc = useQueryClient(); const qc = useQueryClient();
const { toast } = useToast();
const items = useMemo(() => enrichItems(data), [data]); const items = useMemo(() => enrichItems(data), [data]);
const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null); const [confirmDelete, setConfirmDelete] = useState<{ id: string; name: string; count: number } | null>(null);
@@ -58,6 +60,7 @@ export function BinsView({
const remove = useMutation({ const remove = useMutation({
mutationFn: (id: string) => api.deleteBin(id), mutationFn: (id: string) => api.deleteBin(id),
onSuccess: () => { onSuccess: () => {
toast(`Deleted bin "${confirmDelete?.name ?? ""}"`);
qc.invalidateQueries({ queryKey: ["bootstrap"] }); qc.invalidateQueries({ queryKey: ["bootstrap"] });
setConfirmDelete(null); setConfirmDelete(null);
}, },
+4 -2
View File
@@ -20,11 +20,13 @@ export function Dashboard({
data, data,
stats, stats,
onAuditItem, onAuditItem,
onAuditQueue,
onSelectItem, onSelectItem,
}: { }: {
data: Bootstrap; data: Bootstrap;
stats: Stats; stats: Stats;
onAuditItem: (i: Item) => void; onAuditItem: (i: Item) => void;
onAuditQueue: (items: Item[]) => void;
onSelectItem: (i: Item) => void; onSelectItem: (i: Item) => void;
}) { }) {
const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" })); const series30 = stats.series30.map((d) => ({ value: d.grams, label: "" }));
@@ -174,8 +176,8 @@ export function Dashboard({
{overdue.length > 3 && ` · +${overdue.length - 3} more`} {overdue.length > 3 && ` · +${overdue.length - 3} more`}
</div> </div>
</div> </div>
<Btn variant="secondary" icon="check" onClick={() => onAuditItem(overdue[0]!)}> <Btn variant="secondary" icon="search" onClick={() => onAuditQueue(overdue)}>
Run audit Audit {overdue.length > 1 ? `all ${overdue.length}` : ""}
</Btn> </Btn>
</div> </div>
</Card> </Card>
+54 -19
View File
@@ -40,6 +40,7 @@ export function Inventory({
const [filter, setFilter] = useState<FilterKey>("active"); const [filter, setFilter] = useState<FilterKey>("active");
const [typeFilter, setTypeFilter] = useState<string>("all"); const [typeFilter, setTypeFilter] = useState<string>("all");
const [sortBy, setSortBy] = useState<SortKey>("recent"); const [sortBy, setSortBy] = useState<SortKey>("recent");
const [sortAsc, setSortAsc] = useState(false);
const [search, setSearch] = useState(""); const [search, setSearch] = useState("");
const [view, setView] = useState<ViewKey>( const [view, setView] = useState<ViewKey>(
() => (localStorage.getItem("apothecary.inventoryView") as ViewKey | null) ?? "flat", () => (localStorage.getItem("apothecary.inventoryView") as ViewKey | null) ?? "flat",
@@ -49,14 +50,15 @@ export function Inventory({
}, [view]); }, [view]);
const sortFn = (a: Item, b: Item) => { const sortFn = (a: Item, b: Item) => {
if (sortBy === "recent") return +new Date(b.purchaseDate) - +new Date(a.purchaseDate); const dir = sortAsc ? -1 : 1;
if (sortBy === "name") return a.name.localeCompare(b.name); if (sortBy === "recent") return dir * (+new Date(b.purchaseDate) - +new Date(a.purchaseDate));
if (sortBy === "thc") return b.thc - a.thc; if (sortBy === "name") return dir * a.name.localeCompare(b.name);
if (sortBy === "thc") return dir * (b.thc - a.thc);
if (sortBy === "remaining") if (sortBy === "remaining")
return helpers.estimatedRemaining(b, getToday(getStoredTimezone())) - helpers.estimatedRemaining(a, getToday(getStoredTimezone())); return dir * (helpers.estimatedRemaining(b, getToday(getStoredTimezone())) - helpers.estimatedRemaining(a, getToday(getStoredTimezone())));
if (sortBy === "price") return b.price - a.price; if (sortBy === "price") return dir * (b.price - a.price);
if (sortBy === "audit") 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; return 0;
}; };
@@ -84,7 +86,7 @@ export function Inventory({
return out; return out;
}, [items, data, filter, typeFilter, search]); }, [items, data, filter, typeFilter, search]);
const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy]); const sorted = useMemo(() => [...filtered].sort(sortFn), [filtered, sortBy, sortAsc]);
type Group = { type Group = {
productId: string; productId: string;
@@ -112,12 +114,11 @@ export function Inventory({
}); });
} }
out.sort((a, b) => { out.sort((a, b) => {
const aMax = Math.max(...a.items.map((p) => +new Date(p.purchaseDate))); if (a.items.length === 0 || b.items.length === 0) return 0;
const bMax = Math.max(...b.items.map((p) => +new Date(p.purchaseDate))); return sortFn(a.items[0]!, b.items[0]!);
return bMax - aMax;
}); });
return out; return out;
}, [filtered, sortBy]); }, [filtered, sortBy, sortAsc]);
// All visible item IDs in display order (flat or grouped) // All visible item IDs in display order (flat or grouped)
const visibleIds = useMemo(() => { const visibleIds = useMemo(() => {
@@ -255,15 +256,23 @@ export function Inventory({
<Select <Select
value={sortBy} value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortKey)} onChange={(e) => {
const key = e.target.value as SortKey;
if (key === sortBy) {
setSortAsc((prev) => !prev);
} else {
setSortBy(key);
setSortAsc(false);
}
}}
style={{ ...inputStyle, width: "auto", padding: "8px 10px" }} style={{ ...inputStyle, width: "auto", padding: "8px 10px" }}
> >
<option value="recent">Recent first</option> <option value="recent">Recent first</option>
<option value="name">Name (AZ)</option> <option value="name">Name (AZ)</option>
<option value="thc">THC % (high)</option> <option value="thc">THC %</option>
<option value="remaining">Remaining (high)</option> <option value="remaining">Remaining</option>
<option value="price">Price (high)</option> <option value="price">Price</option>
<option value="audit">Audit overdue first</option> <option value="audit">Audit overdue</option>
</Select> </Select>
</div> </div>
</Card> </Card>
@@ -271,7 +280,15 @@ export function Inventory({
<Card padded={false}> <Card padded={false}>
<HeaderRow <HeaderRow
sortBy={sortBy} sortBy={sortBy}
onSort={setSortBy} sortAsc={sortAsc}
onSort={(key) => {
if (key === sortBy) {
setSortAsc((prev) => !prev);
} else {
setSortBy(key);
setSortAsc(false);
}
}}
isAllSelected={isAllSelected} isAllSelected={isAllSelected}
isIndeterminate={isIndeterminate} isIndeterminate={isIndeterminate}
onToggleAll={toggleAll} onToggleAll={toggleAll}
@@ -346,8 +363,20 @@ function Segmented<T extends string>({
options: [T, string][]; options: [T, string][];
onChange: (v: T) => void; 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 ( return (
<div <div
role="tablist"
style={{ style={{
display: "inline-flex", display: "inline-flex",
background: "var(--bg-2)", background: "var(--bg-2)",
@@ -356,10 +385,14 @@ function Segmented<T extends string>({
padding: 3, padding: 3,
}} }}
> >
{options.map(([k, l]) => ( {options.map(([k, l], i) => (
<button <button
key={k} key={k}
role="tab"
aria-selected={value === k}
tabIndex={value === k ? 0 : -1}
onClick={() => onChange(k)} onClick={() => onChange(k)}
onKeyDown={(e) => handleKeyDown(e, i)}
style={{ style={{
padding: "6px 14px", padding: "6px 14px",
fontSize: 12, fontSize: 12,
@@ -384,12 +417,14 @@ const COL_LABELS = ["", "", "Item", "Brand", "Shop", "THC", "Price", "Remaining"
function HeaderRow({ function HeaderRow({
sortBy, sortBy,
sortAsc,
onSort, onSort,
isAllSelected, isAllSelected,
isIndeterminate, isIndeterminate,
onToggleAll, onToggleAll,
}: { }: {
sortBy: SortKey; sortBy: SortKey;
sortAsc: boolean;
onSort: (k: SortKey) => void; onSort: (k: SortKey) => void;
isAllSelected: boolean; isAllSelected: boolean;
isIndeterminate: boolean; isIndeterminate: boolean;
@@ -447,7 +482,7 @@ function HeaderRow({
}} }}
> >
{label} {label}
{active && <span style={{ fontSize: 9 }}></span>} {active && <span style={{ fontSize: 9 }}>{sortAsc ? "▲" : "▼"}</span>}
</button> </button>
); );
})} })}