Typed quantity with undo

Validated typed quantity input that explains bad values, and removal with a 5-second Undo that restores the line in place.

componentrowanycartquantity
$npx shadcn@latest add @storekit/cart-qty-undo
cart-qty-undo

Live and interactive. Switch the brand in the frame header to see the same source under another token file.

Installation

$npx shadcn@latest add @storekit/cart-qty-undo
$pnpm dlx shadcn@latest add @storekit/cart-qty-undo
$yarn dlx shadcn@latest add @storekit/cart-qty-undo
$bunx --bun shadcn@latest add @storekit/cart-qty-undo

Source

components/storekit/cart/cart-qty-undo.tsx
203 lines
"use client"

import * as React from "react"
import { AnimatePresence, motion, useReducedMotion } from "motion/react"
import { Trash2, Undo2 } from "lucide-react"
import { cn, money } from "@/components/storekit/lib/cn"
import { ProductImage } from "@/components/storekit/ui/product-image"
import { useSkBlock } from "@/components/storekit/lib/sk-analytics"

/* Typed quantity with validation, and removal you can take back. The input
   accepts whole numbers within stock, commits on blur or Enter, and explains
   a bad value instead of silently clamping it. Removing a line shows an Undo
   bar for 5 seconds that puts the line back where it was. */

export type UndoLine = { id: string; title: string; variant?: string; price: number; image: string; quantity: number; stock?: number }

const SAMPLE: UndoLine[] = [
  { id: "daily-greens", title: "Daily Greens", variant: "30 servings", price: 4900, image: "/brands/morrow/greens.webp", quantity: 2, stock: 12 },
  { id: "electrolytes", title: "Electrolytes", variant: "30 sticks", price: 3200, image: "/brands/morrow/electrolytes.webp", quantity: 1, stock: 4 },
  { id: "tablets", title: "Magnesium Night", variant: "60 gummies", price: 2900, image: "/brands/morrow/magnesium.webp", quantity: 1, stock: 20 },
]

function QtyInput({ line, onCommit }: { line: UndoLine; onCommit: (q: number) => void }) {
  const max = line.stock ?? 99
  const [draft, setDraft] = React.useState(String(line.quantity))
  const [error, setError] = React.useState<string>()
  const id = React.useId()
  React.useEffect(() => setDraft(String(line.quantity)), [line.quantity])

  const commit = () => {
    const v = draft.trim()
    if (!/^\d+$/.test(v)) return setError("Enter a whole number.")
    const n = Number(v)
    if (n < 1) return setError("Use Remove to take it out.")
    if (n > max) return setError(`Only ${max} in stock.`)
    setError(undefined)
    if (n !== line.quantity) onCommit(n)
  }

  return (
    <div>
      <label htmlFor={id} className="sr-only">
        Quantity for {line.title}
      </label>
      <input
        id={id}
        inputMode="numeric"
        autoComplete="off"
        value={draft}
        aria-invalid={!!error}
        aria-describedby={error ? `${id}-err` : undefined}
        onChange={(e) => {
          setDraft(e.target.value)
          if (error) setError(undefined)
        }}
        onBlur={commit}
        onKeyDown={(e) => {
          if (e.key === "Enter") {
            e.preventDefault()
            commit()
          }
          if (e.key === "Escape") {
            setDraft(String(line.quantity))
            setError(undefined)
          }
        }}
        className={cn(
          "h-10 w-16 rounded-sk-btn border bg-sk-card px-2 text-center text-sm tabular-nums text-sk-ink transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2",
          error ? "border-sk-sale focus-visible:ring-sk-sale/30" : "border-sk-line focus-visible:border-sk-accent focus-visible:ring-sk-accent/30"
        )}
      />
      {error ? (
        <p id={`${id}-err`} role="alert" className="mt-1 text-xs text-sk-sale">
          {error}
        </p>
      ) : null}
    </div>
  )
}

export function CartQtyUndo({
  items = SAMPLE,
  undoMs = 5000,
  onChange,
  contained = true,
  className,
}: {
  items?: UndoLine[]
  undoMs?: number
  onChange?: (lines: UndoLine[]) => void
  /** Keep the Undo bar inside this block. Set false to pin it to the viewport. */
  contained?: boolean
  className?: string
}) {
  const reduce = useReducedMotion()
  const sk = useSkBlock("cart", "cart-qty-undo")
  const [lines, setLines] = React.useState(items)
  const [removed, setRemoved] = React.useState<{ line: UndoLine; index: number } | null>(null)
  const subtotal = lines.reduce((n, l) => n + l.price * l.quantity, 0)

  React.useEffect(() => {
    if (!removed) return
    const t = window.setTimeout(() => setRemoved(null), undoMs)
    return () => window.clearTimeout(t)
  }, [removed, undoMs])

  const commit = (next: UndoLine[]) => {
    setLines(next)
    onChange?.(next)
  }
  const remove = (l: UndoLine) => {
    const index = lines.findIndex((x) => x.id === l.id)
    commit(lines.filter((x) => x.id !== l.id))
    setRemoved({ line: l, index })
    sk.emit("interact", "remove", l.price * l.quantity)
  }
  const undo = () => {
    if (!removed) return
    const next = [...lines]
    next.splice(Math.min(removed.index, next.length), 0, removed.line)
    commit(next)
    setRemoved(null)
    sk.emit("interact", "undo_remove", removed.line.price * removed.line.quantity)
  }

  return (
    <div ref={sk.ref as React.Ref<HTMLDivElement>} {...sk.attrs} className={cn("relative w-full pb-16", className)}>
      <ul className="border-y border-sk-line">
        <AnimatePresence initial={false}>
          {lines.map((l) => (
            <motion.li
              key={l.id}
              layout={!reduce}
              initial={{ opacity: 0, height: 0 }}
              animate={{ opacity: 1, height: "auto" }}
              exit={{ opacity: 0, height: 0 }}
              transition={{ duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] }}
              className="overflow-hidden border-b border-sk-line last:border-b-0"
            >
              <div className="flex gap-3 py-4">
                <ProductImage src={l.image} alt={l.title} className="h-16 w-16 shrink-0 rounded-sk-card border border-sk-line" fit="cover" zoomOnHover={false} />
                <div className="min-w-0 flex-1">
                  <div className="flex items-start justify-between gap-3">
                    <div className="min-w-0">
                      <p className="truncate text-sm font-medium">{l.title}</p>
                      <p className="text-[13px] text-sk-muted">
                        {l.variant ? `${l.variant} / ` : null}
                        <span className="tabular-nums">{money(l.price)}</span> each
                      </p>
                    </div>
                    <p className="shrink-0 text-sm font-semibold tabular-nums text-sk-price">{money(l.price * l.quantity)}</p>
                  </div>
                  <div className="mt-2 flex items-start justify-between gap-3">
                    <QtyInput line={l} onCommit={(q) => commit(lines.map((x) => (x.id === l.id ? { ...x, quantity: q } : x)))} />
                    <button
                      type="button"
                      onClick={() => remove(l)}
                      className="-mr-2 inline-flex h-10 items-center gap-1.5 rounded-sk-btn px-2 text-[13px] text-sk-muted transition-colors duration-200 hover:text-sk-ink focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-accent active:scale-[0.97]"
                    >
                      <Trash2 size={14} />
                      Remove
                    </button>
                  </div>
                </div>
              </div>
            </motion.li>
          ))}
        </AnimatePresence>
      </ul>
      <div className="flex justify-between pt-3 text-sm">
        <span className="text-sk-muted">Subtotal</span>
        <span className="font-semibold tabular-nums">{money(subtotal)}</span>
      </div>

      <div aria-live="polite" className={cn(contained ? "absolute" : "fixed", "inset-x-0 bottom-0 z-20 flex justify-center")}>
        <AnimatePresence>
          {removed ? (
            <motion.div
              key={removed.line.id}
              role="status"
              initial={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}
              animate={{ opacity: 1, y: 0 }}
              exit={reduce ? { opacity: 0 } : { opacity: 0, y: 12 }}
              transition={{ duration: reduce ? 0 : 0.3, ease: [0.16, 1, 0.3, 1] }}
              className="flex w-full max-w-sm items-center gap-3 overflow-hidden rounded-sk-card bg-sk-ink py-1.5 pl-4 pr-1.5 text-sk-paper shadow-[0_24px_60px_-24px_rgb(0_0_0/0.4)]"
            >
              <p className="min-w-0 flex-1 truncate text-[13px]">Removed {removed.line.title}</p>
              <button
                type="button"
                onClick={undo}
                className="inline-flex h-10 shrink-0 items-center gap-1.5 rounded-sk-btn px-3 text-[13px] font-semibold text-sk-paper transition-colors duration-200 hover:bg-sk-paper/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sk-paper active:scale-[0.97]"
              >
                <Undo2 size={14} />
                Undo
              </button>
            </motion.div>
          ) : null}
        </AnimatePresence>
      </div>
    </div>
  )
}

Usage

Import from "@/components/storekit/cart/cart-qty-undo" and pass your product data. The demo above exercises the full prop surface with sample values.

Typed quantity with undo, StoreKit