Shopping Cart Drawer
Machine-coding brief for a cart drawer — line items, qty updates, totals, focus trap, and accessible dialog pattern.
intermediate4 min read
- machine-coding
- interview
- react
- state
- a11y
Problem statement
Build a shopping cart drawer: slide-over panel listing line items with quantity controls, remove, subtotal, and checkout CTA. Interviewers score cart state (merge by product id), derived totals, and drawer a11y (focus, escape, scroll lock) similar to a modal.
Requirements
Must have
- Open/close cart drawer
- List items: title, price, quantity, line total
- Increment / decrement quantity (min 1 or remove at 0 — clarify)
- Remove item
- Subtotal = sum(price * qty)
- Empty cart state
- Escape closes; focus restore; basic focus trap
Should have
- Badge count on cart trigger
- Persist cart to
localStorage - Currency formatting via
Intl.NumberFormat
Nice to have
- Promo code field
- Free-shipping progress bar
- Optimistic stock check
Planning (5 minutes out loud)
- Cart line model — productId, title, unitPrice, qty
- addItem merges qty for same productId
- Drawer = dialog pattern
- MVP — state + list + totals + open/close; then a11y + persist
- Money as integer cents if you want to avoid float bugs
Architecture
CartProvider (optional)
├── CartTrigger (badge)
└── CartDrawer
├── LineItem[]
├── Subtotal
└── CheckoutButton
Types
type CartItem = {
productId: string;
title: string;
unitPrice: number; // cents
qty: number;
};
type CartState = {
items: CartItem[];
};
type CartAction =
| { type: "ADD"; item: Omit<CartItem, "qty"> & { qty?: number } }
| { type: "SET_QTY"; productId: string; qty: number }
| { type: "REMOVE"; productId: string }
| { type: "CLEAR" };
Implementation sketch
Reducer
function cartReducer(state: CartState, action: CartAction): CartState {
switch (action.type) {
case "ADD": {
const qty = action.item.qty ?? 1;
const existing = state.items.find((i) => i.productId === action.item.productId);
if (existing) {
return {
items: state.items.map((i) =>
i.productId === action.item.productId
? { ...i, qty: i.qty + qty }
: i
),
};
}
return {
items: [
...state.items,
{
productId: action.item.productId,
title: action.item.title,
unitPrice: action.item.unitPrice,
qty,
},
],
};
}
case "SET_QTY": {
if (action.qty <= 0) {
return {
items: state.items.filter((i) => i.productId !== action.productId),
};
}
return {
items: state.items.map((i) =>
i.productId === action.productId ? { ...i, qty: action.qty } : i
),
};
}
case "REMOVE":
return { items: state.items.filter((i) => i.productId !== action.productId) };
case "CLEAR":
return { items: [] };
default:
return state;
}
}
function subtotalCents(items: CartItem[]) {
return items.reduce((sum, i) => sum + i.unitPrice * i.qty, 0);
}
function formatMoney(cents: number, currency = "USD") {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
}).format(cents / 100);
}
Drawer shell
function CartDrawer({
open,
onClose,
items,
dispatch,
}: {
open: boolean;
onClose: () => void;
items: CartItem[];
dispatch: React.Dispatch<CartAction>;
}) {
const panelRef = useRef<HTMLDivElement>(null);
const titleId = useId();
useEffect(() => {
if (!open) return;
const prev = document.activeElement as HTMLElement | null;
const prevOverflow = document.body.style.overflow;
document.body.style.overflow = "hidden";
panelRef.current?.querySelector<HTMLElement>("button, [href], input")?.focus();
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
document.addEventListener("keydown", onKey);
return () => {
document.body.style.overflow = prevOverflow;
document.removeEventListener("keydown", onKey);
prev?.focus?.();
};
}, [open, onClose]);
if (!open) return null;
const total = subtotalCents(items);
return (
<div className="cart-root">
<div className="backdrop" onClick={onClose} />
<div
ref={panelRef}
className="drawer"
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
>
<header>
<h2 id={titleId}>Your cart</h2>
<button type="button" aria-label="Close cart" onClick={onClose}>
×
</button>
</header>
{items.length === 0 ? (
<p>Your cart is empty.</p>
) : (
<ul>
{items.map((item) => (
<li key={item.productId}>
<div>
<p>{item.title}</p>
<p>{formatMoney(item.unitPrice)}</p>
</div>
<div>
<button
type="button"
aria-label={`Decrease quantity of ${item.title}`}
onClick={() =>
dispatch({
type: "SET_QTY",
productId: item.productId,
qty: item.qty - 1,
})
}
>
−
</button>
<span aria-live="polite">{item.qty}</span>
<button
type="button"
aria-label={`Increase quantity of ${item.title}`}
onClick={() =>
dispatch({
type: "SET_QTY",
productId: item.productId,
qty: item.qty + 1,
})
}
>
+
</button>
<button
type="button"
aria-label={`Remove ${item.title}`}
onClick={() =>
dispatch({ type: "REMOVE", productId: item.productId })
}
>
Remove
</button>
</div>
<p>{formatMoney(item.unitPrice * item.qty)}</p>
</li>
))}
</ul>
)}
<footer>
<p>Subtotal {formatMoney(total)}</p>
<button type="button" disabled={items.length === 0}>
Checkout
</button>
</footer>
</div>
</div>
);
}
Badge
const count = items.reduce((n, i) => n + i.qty, 0);
// trigger: aria-label={`Cart, ${count} items`}
Accessibility essentials
- Dialog labeling + modal semantics
- Escape + focus return + scroll lock (reuse modal skills)
- Qty buttons named with product title
- Live region for qty optional; don’t over-announce
Performance notes
- Cart is small; reducer is enough
- Persist with
useEffectdebounce write to localStorage - Don’t put entire product catalog into cart state — only line fields
Footguns
- Float money (
0.1 + 0.2) — use cents - Duplicate lines for same product
- Focus not restored when closing
- Checkout enabled on empty cart
- Stale prices if product price changes server-side — revalidate at checkout
Interview out-loud answer
Cart state is a list of lines keyed by productId with merge-on-add. Totals are derived from cents. The drawer is an accessible dialog: focus, escape, scroll lock. MVP is add/update/remove + subtotal; persistence and promo codes later. I’d keep price snapshots on the line and revalidate stock at checkout.
Related on this site
- Accessible Modal Dialog
- Design E-commerce Product Page
- Toast Notification System
- Machine Coding Interview Framework