# Quantity Input Collection with Plus and Minus Stepper, Spinbutton Keys and Cart Line in React and HTML

> Free quantity input steppers for online stores in React and HTML: min, max and step limits, spinbutton keyboard support, decimals and a cart line with totals.

A shadcn/ui block from the [@weinc](https://we.inc/components?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/weinc/quantity-input-collection-with-plus-minus-stepper-spinbutton-keys-and-cart-line-in-react-and-html).

- Page, with a live preview: https://shadcn.exchange/r/weinc/quantity-input-collection-with-plus-minus-stepper-spinbutton-keys-and-cart-line-in-react-and-html
- Screenshot: https://shadcn.exchange/shot/weinc/quantity-input-collection-with-plus-minus-stepper-spinbutton-keys-and-cart-line-in-react-and-html/light.png
- Registry item: https://we.inc/r/quantity-input-collection-with-plus-minus-stepper-spinbutton-keys-and-cart-line-in-react-and-html.json
- Listed: 2026-10-28, source last changed 2026-10-28

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/weinc/quantity-input-collection-with-plus-minus-stepper-spinbutton-keys-and-cart-line-in-react-and-html.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Files

### `components/we-inc/quantity-input-collection-with-plus-minus-stepper-spinbutton-keys-and-cart-line-in-react-and-html.tsx`

```tsx
import { useEffect, useRef, useState, type KeyboardEvent, type ReactElement } from "react";

/*
  Every piece below is written once: the function renders the live demo and the
  string next to it is that same source file, so the code you copy is the code you see.
*/

type Lang = "react" | "html";
type Piece = {
  id: string;
  name: string;
  blurb: string;
  group: string;
  Demo: () => ReactElement;
  react: string;
  html?: string;
  stage: string;
};

const q1Demo = (() => {
const MIN = 1;
const MAX = 10;

function QuantityClassic() {
  const [qty, setQty] = useState(2);
  const [draft, setDraft] = useState("2");

  const set = (n: number) => {
    const v = Math.min(MAX, Math.max(MIN, Math.round(n)));
    setQty(v);
    setDraft(String(v));
  };

  // Spinbutton keys from the WAI-ARIA Authoring Practices.
  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    const keys: Record<string, number> = { ArrowUp: qty + 1, ArrowDown: qty - 1, PageUp: qty + 5, PageDown: qty - 5, Home: MIN, End: MAX };
    if (e.key in keys) {
      e.preventDefault();
      set(keys[e.key]);
    } else if (e.key === "Enter") set(Number(draft) || MIN);
  }

  const btn =
    "grid w-11 place-items-center text-stone-700 transition-colors hover:bg-stone-100 focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-700 aria-disabled:cursor-not-allowed aria-disabled:text-stone-300 aria-disabled:hover:bg-transparent";

  return (
    <div>
      <label htmlFor="qty-classic" className="block text-sm font-semibold text-stone-900">
        Quantity
      </label>
      <div className="mt-2 inline-flex h-11 items-stretch overflow-hidden rounded-lg border border-stone-300 bg-white shadow-sm">
        <button type="button" aria-label="Decrease quantity" aria-controls="qty-classic" aria-disabled={qty <= MIN} onClick={() => qty > MIN && set(qty - 1)} className={btn}>
          <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4 fill-none stroke-current" strokeWidth={2} strokeLinecap="round">
            <path d="M5 10h10" />
          </svg>
        </button>
        <input
          id="qty-classic"
          type="text"
          inputMode="numeric"
          role="spinbutton"
          aria-valuenow={qty}
          aria-valuemin={MIN}
          aria-valuemax={MAX}
          aria-describedby="qty-classic-help"
          value={draft}
          onChange={(e) => setDraft(e.target.value.replace(/\D/g, "").slice(0, 2))}
          onBlur={() => set(Number(draft) || MIN)}
          onKeyDown={onKeyDown}
          className="w-14 border-x border-stone-300 text-center text-base font-semibold tabular-nums text-stone-900 outline-none focus-visible:bg-emerald-50"
        />
        <button type="button" aria-label="Increase quantity" aria-controls="qty-classic" aria-disabled={qty >= MAX} onClick={() => qty < MAX && set(qty + 1)} className={btn}>
          <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4 fill-none stroke-current" strokeWidth={2} strokeLinecap="round">
            <path d="M5 10h10M10 5v10" />
          </svg>
        </button>
      </div>
      <p id="qty-classic-help" className="mt-2 text-xs text-stone-600">
        Limit {MAX} per order. Arrow keys, Page Up and Page Down work too.
      </p>
    </div>
  );
}
  return QuantityClassic;
})();
const q1React = "import { useState, type KeyboardEvent } from \"react\";\n\nconst MIN = 1;\nconst MAX = 10;\n\nexport default function QuantityClassic() {\n  const [qty, setQty] = useState(2);\n  const [draft, setDraft] = useState(\"2\");\n\n  const set = (n: number) => {\n    const v = Math.min(MAX, Math.max(MIN, Math.round(n)));\n    setQty(v);\n    setDraft(String(v));\n  };\n\n  // Spinbutton keys from the WAI-ARIA Authoring Practices.\n  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {\n    const keys: Record<string, number> = { ArrowUp: qty + 1, ArrowDown: qty - 1, PageUp: qty + 5, PageDown: qty - 5, Home: MIN, End: MAX };\n    if (e.key in keys) {\n      e.preventDefault();\n      set(keys[e.key]);\n    } else if (e.key === \"Enter\") set(Number(draft) || MIN);\n  }\n\n  const btn =\n    \"grid w-11 place-items-center text-stone-700 transition-colors hover:bg-stone-100 focus-visible:z-10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-700 aria-disabled:cursor-not-allowed aria-disabled:text-stone-300 aria-disabled:hover:bg-transparent\";\n\n  return (\n    <div>\n      <label htmlFor=\"qty-classic\" className=\"block text-sm font-semibold text-stone-900\">\n        Quantity\n      </label>\n      <div className=\"mt-2 inline-flex h-11 items-stretch overflow-hidden rounded-lg border border-stone-300 bg-white shadow-sm\">\n        <button type=\"button\" aria-label=\"Decrease quantity\" aria-controls=\"qty-classic\" aria-disabled={qty <= MIN} onClick={() => qty > MIN && set(qty - 1)} className={btn}>\n          <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\" className=\"h-4 w-4 fill-none stroke-current\" strokeWidth={2} strokeLinecap=\"round\">\n            <path d=\"M5 10h10\" />\n          </svg>\n        </button>\n        <input\n          id=\"qty-classic\"\n          type=\"text\"\n          inputMode=\"numeric\"\n          role=\"spinbutton\"\n          aria-valuenow={qty}\n          aria-valuemin={MIN}\n          aria-valuemax={MAX}\n          aria-describedby=\"qty-classic-help\"\n          value={draft}\n          onChange={(e) => setDraft(e.target.value.replace(/\\D/g, \"\").slice(0, 2))}\n          onBlur={() => set(Number(draft) || MIN)}\n          onKeyDown={onKeyDown}\n          className=\"w-14 border-x border-stone-300 text-center text-base font-semibold tabular-nums text-stone-900 outline-none focus-visible:bg-emerald-50\"\n        />\n        <button type=\"button\" aria-label=\"Increase quantity\" aria-controls=\"qty-classic\" aria-disabled={qty >= MAX} onClick={() => qty < MAX && set(qty + 1)} className={btn}>\n          <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\" className=\"h-4 w-4 fill-none stroke-current\" strokeWidth={2} strokeLinecap=\"round\">\n            <path d=\"M5 10h10M10 5v10\" />\n          </svg>\n        </button>\n      </div>\n      <p id=\"qty-classic-help\" className=\"mt-2 text-xs text-stone-600\">\n        Limit {MAX} per order. Arrow keys, Page Up and Page Down work too.\n      </p>\n    </div>\n  );\n}\n";

const q1Html = `<div class="qty" data-min="1" data-max="10">
  <label for="qty-classic" class="qty-label">Quantity</label>
  <div class="qty-box">
    <button type="button" class="qty-btn" data-step="-1" aria-label="Decrease quantity" aria-controls="qty-classic">
      <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10h10"/></svg>
    </button>
    <input id="qty-classic" class="qty-input" type="text" inputmode="numeric" role="spinbutton"
      value="2" aria-valuenow="2" aria-valuemin="1" aria-valuemax="10" aria-describedby="qty-classic-help">
    <button type="button" class="qty-btn" data-step="1" aria-label="Increase quantity" aria-controls="qty-classic">
      <svg viewBox="0 0 20 20" aria-hidden="true"><path d="M5 10h10M10 5v10"/></svg>
    </button>
  </div>
  <p id="qty-classic-help" class="qty-help">Limit 10 per order. Arrow keys, Page Up and Page Down work too.</p>
</div>

<style>
  .qty { font-family: system-ui, sans-serif; color: #1c1917; }
  .qty-label { display: block; font-size: 14px; font-weight: 600; }
  .qty-box { display: inline-flex; height: 44px; margin-top: 8px; overflow: hidden;
    border: 1px solid #d6d3d1; border-radius: 8px; background: #fff; box-shadow: 0 1px 2px rgb(0 0 0 / 0.05); }
  .qty-btn { display: grid; place-items: center; width: 44px; border: 0; background: none; color: #44403c; cursor: pointer; }
  .qty-btn:hover { background: #f5f5f4; }
  .qty-btn[aria-disabled="true"] { color: #d6d3d1; cursor: not-allowed; background: none; }
  .qty-btn:focus-visible { outline: 2px solid #047857; outline-offset: -2px; }
  .qty-btn svg { width: 16px; height: 16px; fill: none; stroke: currentColor; stroke-width: 2; stroke-linecap: round; }
  .qty-input { width: 56px; border: 0; border-inline: 1px solid #d6d3d1; text-align: center;
    font: 600 16px system-ui, sans-serif; font-variant-numeric: tabular-nums; color: #1c1917; outline: none; }
  .qty-input:focus-visible { background: #ecfdf5; }
  .qty-help { margin: 8px 0 0; font-size: 12px; color: #57534e; }
</style>

<script>
  document.querySelectorAll(".qty").forEach((root) => {
    const input = root.querySelector(".qty-input");
    const [dec, inc] = root.querySelectorAll(".qty-btn");
    const min = Number(root.dataset.min), max = Number(root.dataset.max);
    let value = Number(input.value);

    function set(n) {
      value = Math.min(max, Math.max(min, Math.round(n) || min));
      input.value = value;
      input.setAttribute("aria-valuenow", value);
      dec.setAttribute("aria-disabled", value <= min);
      inc.setAttribute("aria-disabled", value >= max);
    }

    root.querySelectorAll(".qty-btn").forEach((b) =>
      b.addEventListener("click", () => set(value + Number(b.dataset.step)))
    );
    input.addEventListener("input", () => { input.value = input.value.replace(/\\D/g, "").slice(0, 2); });
    input.addEventListener("blur", () => set(Number(input.value)));
    input.addEventListener("keydown", (e) => {
      const keys = { ArrowUp: value + 1, ArrowDown: value - 1, PageUp: value + 5, PageDown: value - 5, Home: min, End: max };
      if (e.key in keys) { e.preventDefault(); set(keys[e.key]); }
      else if (e.key === "Enter") set(Number(input.value));
    });
    set(value);
  });
</script>
`;

const q3Demo = (() => {
const STEP = 6; // sold by the case
const MIN = 6;
const MAX = 48;

function QuantityCasePacks() {
  const [qty, setQty] = useState(12);
  const [draft, setDraft] = useState("12");
  const [note, setNote] = useState("");

  // Snaps any typed number to the nearest whole case.
  function commit(n: number) {
    const snapped = Math.min(MAX, Math.max(MIN, Math.round(n / STEP) * STEP));
    setNote(n && snapped !== n ? `Rounded ${n} to ${snapped}, a full case.` : "");
    setQty(snapped);
    setDraft(String(snapped));
  }

  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    const keys: Record<string, number> = { ArrowUp: qty + STEP, ArrowDown: qty - STEP, Home: MIN, End: MAX };
    if (e.key in keys) {
      e.preventDefault();
      commit(keys[e.key]);
    } else if (e.key === "Enter") commit(Number(draft));
  }

  const cases = qty / STEP;
  const side =
    "flex h-12 w-12 items-center justify-center rounded-xl border border-stone-300 bg-white text-lg font-medium text-stone-800 hover:border-stone-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 disabled:cursor-not-allowed disabled:text-stone-300";

  return (
    <div className="w-full max-w-[16rem]">
      <label htmlFor="qty-case" className="block text-sm font-semibold text-stone-900">
        Sparkling water, bottles
      </label>
      <p id="qty-case-help" className="mt-0.5 text-xs text-stone-600">
        Sold in cases of {STEP}, up to {MAX} bottles.
      </p>
      <div className="mt-3 flex items-center gap-2">
        <button type="button" aria-label={`Remove a case of ${STEP}`} disabled={qty <= MIN} onClick={() => commit(qty - STEP)} className={side}>
          -{STEP}
        </button>
        <div className="flex h-12 flex-1 flex-col items-center justify-center rounded-xl bg-emerald-50">
          <input
            id="qty-case"
            type="text"
            inputMode="numeric"
            role="spinbutton"
            aria-valuenow={qty}
            aria-valuemin={MIN}
            aria-valuemax={MAX}
            aria-valuetext={`${qty} bottles, ${cases} ${cases === 1 ? "case" : "cases"}`}
            aria-describedby="qty-case-help"
            value={draft}
            onChange={(e) => setDraft(e.target.value.replace(/\D/g, "").slice(0, 3))}
            onBlur={() => commit(Number(draft))}
            onKeyDown={onKeyDown}
            className="w-16 rounded bg-transparent text-center text-base font-semibold leading-tight tabular-nums text-emerald-950 outline-none focus-visible:ring-2 focus-visible:ring-emerald-700"
          />
          <span aria-hidden="true" className="text-[11px] leading-tight text-emerald-800">
            {cases} {cases === 1 ? "case" : "cases"}
          </span>
        </div>
        <button type="button" aria-label={`Add a case of ${STEP}`} disabled={qty >= MAX} onClick={() => commit(qty + STEP)} className={side}>
          +{STEP}
        </button>
      </div>
      <p role="status" className="mt-2 min-h-[1rem] text-xs text-stone-600">
        {note}
      </p>
    </div>
  );
}
  return QuantityCasePacks;
})();
const q3React = "import { useState, type KeyboardEvent } from \"react\";\n\nconst STEP = 6; // sold by the case\nconst MIN = 6;\nconst MAX = 48;\n\nexport default function QuantityCasePacks() {\n  const [qty, setQty] = useState(12);\n  const [draft, setDraft] = useState(\"12\");\n  const [note, setNote] = useState(\"\");\n\n  // Snaps any typed number to the nearest whole case.\n  function commit(n: number) {\n    const snapped = Math.min(MAX, Math.max(MIN, Math.round(n / STEP) * STEP));\n    setNote(n && snapped !== n ? `Rounded ${n} to ${snapped}, a full case.` : \"\");\n    setQty(snapped);\n    setDraft(String(snapped));\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {\n    const keys: Record<string, number> = { ArrowUp: qty + STEP, ArrowDown: qty - STEP, Home: MIN, End: MAX };\n    if (e.key in keys) {\n      e.preventDefault();\n      commit(keys[e.key]);\n    } else if (e.key === \"Enter\") commit(Number(draft));\n  }\n\n  const cases = qty / STEP;\n  const side =\n    \"flex h-12 w-12 items-center justify-center rounded-xl border border-stone-300 bg-white text-lg font-medium text-stone-800 hover:border-stone-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 disabled:cursor-not-allowed disabled:text-stone-300\";\n\n  return (\n    <div className=\"w-full max-w-[16rem]\">\n      <label htmlFor=\"qty-case\" className=\"block text-sm font-semibold text-stone-900\">\n        Sparkling water, bottles\n      </label>\n      <p id=\"qty-case-help\" className=\"mt-0.5 text-xs text-stone-600\">\n        Sold in cases of {STEP}, up to {MAX} bottles.\n      </p>\n      <div className=\"mt-3 flex items-center gap-2\">\n        <button type=\"button\" aria-label={`Remove a case of ${STEP}`} disabled={qty <= MIN} onClick={() => commit(qty - STEP)} className={side}>\n          -{STEP}\n        </button>\n        <div className=\"flex h-12 flex-1 flex-col items-center justify-center rounded-xl bg-emerald-50\">\n          <input\n            id=\"qty-case\"\n            type=\"text\"\n            inputMode=\"numeric\"\n            role=\"spinbutton\"\n            aria-valuenow={qty}\n            aria-valuemin={MIN}\n            aria-valuemax={MAX}\n            aria-valuetext={`${qty} bottles, ${cases} ${cases === 1 ? \"case\" : \"cases\"}`}\n            aria-describedby=\"qty-case-help\"\n            value={draft}\n            onChange={(e) => setDraft(e.target.value.replace(/\\D/g, \"\").slice(0, 3))}\n            onBlur={() => commit(Number(draft))}\n            onKeyDown={onKeyDown}\n            className=\"w-16 rounded bg-transparent text-center text-base font-semibold leading-tight tabular-nums text-emerald-950 outline-none focus-visible:ring-2 focus-visible:ring-emerald-700\"\n          />\n          <span aria-hidden=\"true\" className=\"text-[11px] leading-tight text-emerald-800\">\n            {cases} {cases === 1 ? \"case\" : \"cases\"}\n          </span>\n        </div>\n        <button type=\"button\" aria-label={`Add a case of ${STEP}`} disabled={qty >= MAX} onClick={() => commit(qty + STEP)} className={side}>\n          +{STEP}\n        </button>\n      </div>\n      <p role=\"status\" className=\"mt-2 min-h-[1rem] text-xs text-stone-600\">\n        {note}\n      </p>\n    </div>\n  );\n}\n";

const q5Demo = (() => {
// Work in whole quarter-kilos so 0.1 + 0.2 style rounding errors never show up.
const STEP_Q = 1; // 0.25 kg
const MIN_Q = 1;
const MAX_Q = 20; // 5 kg
const PRICE_PER_KG = 18;

function QuantityWeightDecimal() {
  const [q, setQ] = useState(5);
  const [draft, setDraft] = useState("1.25");
  const kg = (n: number) => (n / 4).toFixed(2);

  function set(n: number) {
    const v = Math.min(MAX_Q, Math.max(MIN_Q, Math.round(n)));
    setQ(v);
    setDraft(kg(v));
  }

  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {
    const keys: Record<string, number> = { ArrowUp: q + STEP_Q, ArrowDown: q - STEP_Q, PageUp: q + 4, PageDown: q - 4, Home: MIN_Q, End: MAX_Q };
    if (e.key in keys) {
      e.preventDefault();
      set(keys[e.key]);
    } else if (e.key === "Enter") set(parseFloat(draft) * 4 || MIN_Q);
  }

  const arrow =
    "grid h-1/2 w-11 place-items-center text-stone-600 hover:bg-stone-100 hover:text-stone-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-700 disabled:text-stone-300";

  return (
    <div className="w-full max-w-[16rem]">
      <label htmlFor="qty-weight" className="block text-sm font-semibold text-stone-900">
        Arabica beans, whole
      </label>
      <div className="mt-2 flex h-14 overflow-hidden rounded-xl border border-stone-300 bg-white focus-within:border-emerald-700">
        <div className="flex flex-1 items-baseline gap-1 px-3 pt-3">
          <input
            id="qty-weight"
            type="text"
            inputMode="decimal"
            role="spinbutton"
            aria-valuenow={q / 4}
            aria-valuemin={MIN_Q / 4}
            aria-valuemax={MAX_Q / 4}
            aria-valuetext={`${kg(q)} kilograms`}
            value={draft}
            onChange={(e) => setDraft(e.target.value.replace(/[^0-9.]/g, "").slice(0, 5))}
            onBlur={() => set(parseFloat(draft) * 4 || MIN_Q)}
            onKeyDown={onKeyDown}
            className="w-full min-w-0 bg-transparent text-xl font-semibold tabular-nums text-stone-900 outline-none"
          />
          <span aria-hidden="true" className="text-sm text-stone-600">
            kg
          </span>
        </div>
        <div className="flex flex-col border-l border-stone-300">
          <button type="button" aria-label="Add 250 grams" aria-controls="qty-weight" disabled={q >= MAX_Q} onClick={() => set(q + STEP_Q)} className={`${arrow} border-b border-stone-300`}>
            <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4 fill-none stroke-current" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
              <path d="M6 12l4-4 4 4" />
            </svg>
          </button>
          <button type="button" aria-label="Remove 250 grams" aria-controls="qty-weight" disabled={q <= MIN_Q} onClick={() => set(q - STEP_Q)} className={arrow}>
            <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4 fill-none stroke-current" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round">
              <path d="M6 8l4 4 4-4" />
            </svg>
          </button>
        </div>
      </div>
      <dl className="mt-3 flex justify-between text-sm">
        <dt className="text-stone-600">${PRICE_PER_KG.toFixed(2)} per kg</dt>
        <dd className="font-semibold tabular-nums text-stone-900">${((q / 4) * PRICE_PER_KG).toFixed(2)}</dd>
      </dl>
    </div>
  );
}
  return QuantityWeightDecimal;
})();
const q5React = "import { useState, type KeyboardEvent } from \"react\";\n\n// Work in whole quarter-kilos so 0.1 + 0.2 style rounding errors never show up.\nconst STEP_Q = 1; // 0.25 kg\nconst MIN_Q = 1;\nconst MAX_Q = 20; // 5 kg\nconst PRICE_PER_KG = 18;\n\nexport default function QuantityWeightDecimal() {\n  const [q, setQ] = useState(5);\n  const [draft, setDraft] = useState(\"1.25\");\n  const kg = (n: number) => (n / 4).toFixed(2);\n\n  function set(n: number) {\n    const v = Math.min(MAX_Q, Math.max(MIN_Q, Math.round(n)));\n    setQ(v);\n    setDraft(kg(v));\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLInputElement>) {\n    const keys: Record<string, number> = { ArrowUp: q + STEP_Q, ArrowDown: q - STEP_Q, PageUp: q + 4, PageDown: q - 4, Home: MIN_Q, End: MAX_Q };\n    if (e.key in keys) {\n      e.preventDefault();\n      set(keys[e.key]);\n    } else if (e.key === \"Enter\") set(parseFloat(draft) * 4 || MIN_Q);\n  }\n\n  const arrow =\n    \"grid h-1/2 w-11 place-items-center text-stone-600 hover:bg-stone-100 hover:text-stone-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-700 disabled:text-stone-300\";\n\n  return (\n    <div className=\"w-full max-w-[16rem]\">\n      <label htmlFor=\"qty-weight\" className=\"block text-sm font-semibold text-stone-900\">\n        Arabica beans, whole\n      </label>\n      <div className=\"mt-2 flex h-14 overflow-hidden rounded-xl border border-stone-300 bg-white focus-within:border-emerald-700\">\n        <div className=\"flex flex-1 items-baseline gap-1 px-3 pt-3\">\n          <input\n            id=\"qty-weight\"\n            type=\"text\"\n            inputMode=\"decimal\"\n            role=\"spinbutton\"\n            aria-valuenow={q / 4}\n            aria-valuemin={MIN_Q / 4}\n            aria-valuemax={MAX_Q / 4}\n            aria-valuetext={`${kg(q)} kilograms`}\n            value={draft}\n            onChange={(e) => setDraft(e.target.value.replace(/[^0-9.]/g, \"\").slice(0, 5))}\n            onBlur={() => set(parseFloat(draft) * 4 || MIN_Q)}\n            onKeyDown={onKeyDown}\n            className=\"w-full min-w-0 bg-transparent text-xl font-semibold tabular-nums text-stone-900 outline-none\"\n          />\n          <span aria-hidden=\"true\" className=\"text-sm text-stone-600\">\n            kg\n          </span>\n        </div>\n        <div className=\"flex flex-col border-l border-stone-300\">\n          <button type=\"button\" aria-label=\"Add 250 grams\" aria-controls=\"qty-weight\" disabled={q >= MAX_Q} onClick={() => set(q + STEP_Q)} className={`${arrow} border-b border-stone-300`}>\n            <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\" className=\"h-4 w-4 fill-none stroke-current\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <path d=\"M6 12l4-4 4 4\" />\n            </svg>\n          </button>\n          <button type=\"button\" aria-label=\"Remove 250 grams\" aria-controls=\"qty-weight\" disabled={q <= MIN_Q} onClick={() => set(q - STEP_Q)} className={arrow}>\n            <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\" className=\"h-4 w-4 fill-none stroke-current\" strokeWidth={2} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n              <path d=\"M6 8l4 4 4-4\" />\n            </svg>\n          </button>\n        </div>\n      </div>\n      <dl className=\"mt-3 flex justify-between text-sm\">\n        <dt className=\"text-stone-600\">${PRICE_PER_KG.toFixed(2)} per kg</dt>\n        <dd className=\"font-semibold tabular-nums text-stone-900\">${((q / 4) * PRICE_PER_KG).toFixed(2)}</dd>\n      </dl>\n    </div>\n  );\n}\n";

const q2Demo = (() => {
const product = { name: "Oat milk, barista", size: "1 litre carton", price: 3.4, max: 12 };

function QuantityPillCard() {
  const [qty, setQty] = useState(3);
  const set = (n: number) => setQty(Math.min(product.max, Math.max(0, n)));

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    const keys: Record<string, number> = { ArrowUp: qty + 1, ArrowRight: qty + 1, ArrowDown: qty - 1, ArrowLeft: qty - 1, Home: 0, End: product.max };
    if (e.key in keys) {
      e.preventDefault();
      set(keys[e.key]);
    }
  }

  const round =
    "grid h-10 w-10 place-items-center rounded-full text-white transition-colors hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white disabled:opacity-40";

  return (
    <article className="w-full max-w-[15rem] rounded-2xl border border-stone-200 bg-white p-4 shadow-sm">
      <div aria-hidden="true" className="grid h-24 place-items-center rounded-xl bg-[#efe9df]">
        <div className="h-16 w-10 rounded-t-md rounded-b-sm bg-white shadow-[inset_0_-14px_0_#d9c7a8]" />
      </div>
      <h4 id="pill-name" className="mt-3 text-sm font-semibold text-stone-900">
        {product.name}
      </h4>
      <p className="text-xs text-stone-600">{product.size}</p>
      <div className="mt-3 flex items-center justify-between gap-2">
        <span className="text-sm font-semibold tabular-nums text-stone-900">${(product.price * Math.max(qty, 1)).toFixed(2)}</span>
        <div className="flex items-center rounded-full bg-stone-900 p-0.5">
          <button type="button" aria-label="One fewer" disabled={qty === 0} onClick={() => set(qty - 1)} className={round}>
            <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4 fill-none stroke-current" strokeWidth={2.2} strokeLinecap="round">
              <path d="M5 10h10" />
            </svg>
          </button>
          <div
            role="spinbutton"
            tabIndex={0}
            aria-labelledby="pill-name"
            aria-valuenow={qty}
            aria-valuemin={0}
            aria-valuemax={product.max}
            aria-valuetext={`${qty} ${qty === 1 ? "carton" : "cartons"}`}
            onKeyDown={onKeyDown}
            className="min-w-[1.75rem] rounded text-center text-sm font-semibold tabular-nums text-white outline-none focus-visible:ring-2 focus-visible:ring-white"
          >
            {qty}
          </div>
          <button type="button" aria-label="One more" disabled={qty === product.max} onClick={() => set(qty + 1)} className={round}>
            <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4 fill-none stroke-current" strokeWidth={2.2} strokeLinecap="round">
              <path d="M5 10h10M10 5v10" />
            </svg>
          </button>
        </div>
      </div>
    </article>
  );
}
  return QuantityPillCard;
})();
const q2React = "import { useState, type KeyboardEvent } from \"react\";\n\nconst product = { name: \"Oat milk, barista\", size: \"1 litre carton\", price: 3.4, max: 12 };\n\nexport default function QuantityPillCard() {\n  const [qty, setQty] = useState(3);\n  const set = (n: number) => setQty(Math.min(product.max, Math.max(0, n)));\n\n  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {\n    const keys: Record<string, number> = { ArrowUp: qty + 1, ArrowRight: qty + 1, ArrowDown: qty - 1, ArrowLeft: qty - 1, Home: 0, End: product.max };\n    if (e.key in keys) {\n      e.preventDefault();\n      set(keys[e.key]);\n    }\n  }\n\n  const round =\n    \"grid h-10 w-10 place-items-center rounded-full text-white transition-colors hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white disabled:opacity-40\";\n\n  return (\n    <article className=\"w-full max-w-[15rem] rounded-2xl border border-stone-200 bg-white p-4 shadow-sm\">\n      <div aria-hidden=\"true\" className=\"grid h-24 place-items-center rounded-xl bg-[#efe9df]\">\n        <div className=\"h-16 w-10 rounded-t-md rounded-b-sm bg-white shadow-[inset_0_-14px_0_#d9c7a8]\" />\n      </div>\n      <h4 id=\"pill-name\" className=\"mt-3 text-sm font-semibold text-stone-900\">\n        {product.name}\n      </h4>\n      <p className=\"text-xs text-stone-600\">{product.size}</p>\n      <div className=\"mt-3 flex items-center justify-between gap-2\">\n        <span className=\"text-sm font-semibold tabular-nums text-stone-900\">${(product.price * Math.max(qty, 1)).toFixed(2)}</span>\n        <div className=\"flex items-center rounded-full bg-stone-900 p-0.5\">\n          <button type=\"button\" aria-label=\"One fewer\" disabled={qty === 0} onClick={() => set(qty - 1)} className={round}>\n            <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\" className=\"h-4 w-4 fill-none stroke-current\" strokeWidth={2.2} strokeLinecap=\"round\">\n              <path d=\"M5 10h10\" />\n            </svg>\n          </button>\n          <div\n            role=\"spinbutton\"\n            tabIndex={0}\n            aria-labelledby=\"pill-name\"\n            aria-valuenow={qty}\n            aria-valuemin={0}\n            aria-valuemax={product.max}\n            aria-valuetext={`${qty} ${qty === 1 ? \"carton\" : \"cartons\"}`}\n            onKeyDown={onKeyDown}\n            className=\"min-w-[1.75rem] rounded text-center text-sm font-semibold tabular-nums text-white outline-none focus-visible:ring-2 focus-visible:ring-white\"\n          >\n            {qty}\n          </div>\n          <button type=\"button\" aria-label=\"One more\" disabled={qty === product.max} onClick={() => set(qty + 1)} className={round}>\n            <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\" className=\"h-4 w-4 fill-none stroke-current\" strokeWidth={2.2} strokeLinecap=\"round\">\n              <path d=\"M5 10h10M10 5v10\" />\n            </svg>\n          </button>\n        </div>\n      </div>\n    </article>\n  );\n}\n";

const q4Demo = (() => {
const MAX = 9;

function AddToCartStepper() {
  const [qty, setQty] = useState(1);
  const [message, setMessage] = useState("");
  const addRef = useRef<HTMLButtonElement>(null);
  const valueRef = useRef<HTMLDivElement>(null);
  const moveFocus = useRef(false);

  // After the button swaps for the stepper (or back), keep keyboard focus in a sensible place.
  useEffect(() => {
    if (!moveFocus.current) return;
    moveFocus.current = false;
    (qty === 0 ? addRef.current : valueRef.current)?.focus();
  }, [qty]);

  function change(n: number, swap = false) {
    const v = Math.min(MAX, Math.max(0, n));
    moveFocus.current = swap;
    setQty(v);
    setMessage(v === 0 ? "Removed from cart" : `${v} in cart`);
  }

  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {
    if (e.key === "ArrowUp" || e.key === "ArrowRight") change(qty + 1);
    else if (e.key === "ArrowDown" || e.key === "ArrowLeft") change(qty - 1, qty === 1);
    else return;
    e.preventDefault();
  }

  const icon = "grid h-11 w-11 place-items-center rounded-lg text-white hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white disabled:opacity-40";

  return (
    <div className="w-full max-w-[15rem]">
      <p className="text-sm font-semibold text-stone-900">Linen tea towel, olive</p>
      <p className="text-xs text-stone-600">$14.00</p>
      <div className="mt-3 h-11">
        {qty === 0 ? (
          <button
            ref={addRef}
            type="button"
            onClick={() => change(1, true)}
            className="h-11 w-full rounded-lg bg-emerald-800 text-sm font-semibold text-white transition-colors hover:bg-emerald-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-800 focus-visible:ring-offset-2"
          >
            Add to cart
          </button>
        ) : (
          <div className="flex h-11 items-center justify-between rounded-lg bg-emerald-800">
            <button type="button" aria-label={qty === 1 ? "Remove from cart" : "Decrease quantity"} onClick={() => change(qty - 1, qty === 1)} className={icon}>
              <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4 fill-none stroke-current" strokeWidth={1.8} strokeLinecap="round" strokeLinejoin="round">
                <path d={qty === 1 ? "M4 6h12M8 6V4h4v2M6 6l1 10h6l1-10" : "M5 10h10"} />
              </svg>
            </button>
            <div
              ref={valueRef}
              role="spinbutton"
              tabIndex={0}
              aria-label="Tea towels in cart"
              aria-valuenow={qty}
              aria-valuemin={0}
              aria-valuemax={MAX}
              onKeyDown={onKeyDown}
              className="rounded px-2 text-sm font-semibold tabular-nums text-white outline-none focus-visible:ring-2 focus-visible:ring-white"
            >
              {qty} in cart
            </div>
            <button type="button" aria-label="Increase quantity" disabled={qty >= MAX} onClick={() => change(qty + 1)} className={icon}>
              <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4 fill-none stroke-current" strokeWidth={1.8} strokeLinecap="round">
                <path d="M5 10h10M10 5v10" />
              </svg>
            </button>
          </div>
        )}
      </div>
      <p role="status" className="mt-2 min-h-[1rem] text-xs text-stone-600">
        {message}
      </p>
    </div>
  );
}
  return AddToCartStepper;
})();
const q4React = "import { useEffect, useRef, useState, type KeyboardEvent } from \"react\";\n\nconst MAX = 9;\n\nexport default function AddToCartStepper() {\n  const [qty, setQty] = useState(1);\n  const [message, setMessage] = useState(\"\");\n  const addRef = useRef<HTMLButtonElement>(null);\n  const valueRef = useRef<HTMLDivElement>(null);\n  const moveFocus = useRef(false);\n\n  // After the button swaps for the stepper (or back), keep keyboard focus in a sensible place.\n  useEffect(() => {\n    if (!moveFocus.current) return;\n    moveFocus.current = false;\n    (qty === 0 ? addRef.current : valueRef.current)?.focus();\n  }, [qty]);\n\n  function change(n: number, swap = false) {\n    const v = Math.min(MAX, Math.max(0, n));\n    moveFocus.current = swap;\n    setQty(v);\n    setMessage(v === 0 ? \"Removed from cart\" : `${v} in cart`);\n  }\n\n  function onKeyDown(e: KeyboardEvent<HTMLDivElement>) {\n    if (e.key === \"ArrowUp\" || e.key === \"ArrowRight\") change(qty + 1);\n    else if (e.key === \"ArrowDown\" || e.key === \"ArrowLeft\") change(qty - 1, qty === 1);\n    else return;\n    e.preventDefault();\n  }\n\n  const icon = \"grid h-11 w-11 place-items-center rounded-lg text-white hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white disabled:opacity-40\";\n\n  return (\n    <div className=\"w-full max-w-[15rem]\">\n      <p className=\"text-sm font-semibold text-stone-900\">Linen tea towel, olive</p>\n      <p className=\"text-xs text-stone-600\">$14.00</p>\n      <div className=\"mt-3 h-11\">\n        {qty === 0 ? (\n          <button\n            ref={addRef}\n            type=\"button\"\n            onClick={() => change(1, true)}\n            className=\"h-11 w-full rounded-lg bg-emerald-800 text-sm font-semibold text-white transition-colors hover:bg-emerald-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-800 focus-visible:ring-offset-2\"\n          >\n            Add to cart\n          </button>\n        ) : (\n          <div className=\"flex h-11 items-center justify-between rounded-lg bg-emerald-800\">\n            <button type=\"button\" aria-label={qty === 1 ? \"Remove from cart\" : \"Decrease quantity\"} onClick={() => change(qty - 1, qty === 1)} className={icon}>\n              <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\" className=\"h-4 w-4 fill-none stroke-current\" strokeWidth={1.8} strokeLinecap=\"round\" strokeLinejoin=\"round\">\n                <path d={qty === 1 ? \"M4 6h12M8 6V4h4v2M6 6l1 10h6l1-10\" : \"M5 10h10\"} />\n              </svg>\n            </button>\n            <div\n              ref={valueRef}\n              role=\"spinbutton\"\n              tabIndex={0}\n              aria-label=\"Tea towels in cart\"\n              aria-valuenow={qty}\n              aria-valuemin={0}\n              aria-valuemax={MAX}\n              onKeyDown={onKeyDown}\n              className=\"rounded px-2 text-sm font-semibold tabular-nums text-white outline-none focus-visible:ring-2 focus-visible:ring-white\"\n            >\n              {qty} in cart\n            </div>\n            <button type=\"button\" aria-label=\"Increase quantity\" disabled={qty >= MAX} onClick={() => change(qty + 1)} className={icon}>\n              <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\" className=\"h-4 w-4 fill-none stroke-current\" strokeWidth={1.8} strokeLinecap=\"round\">\n                <path d=\"M5 10h10M10 5v10\" />\n              </svg>\n            </button>\n          </div>\n        )}\n      </div>\n      <p role=\"status\" className=\"mt-2 min-h-[1rem] text-xs text-stone-600\">\n        {message}\n      </p>\n    </div>\n  );\n}\n";

const q6Demo = (() => {
type Line = { id: string; name: string; variant: string; price: number; qty: number; max: number };

const START: Line[] = [
  { id: "apron", name: "Linen apron", variant: "Sand, one size", price: 28, qty: 1, max: 5 },
  { id: "wraps", name: "Beeswax wraps", variant: "Set of 3", price: 16, qty: 2, max: 10 },
];

const money = (n: number) => `$${n.toFixed(2)}`;

function CartLineQuantity() {
  const [lines, setLines] = useState<Line[]>(START);
  const [status, setStatus] = useState("");

  function setQty(id: string, n: number) {
    const next = lines.map((l) => (l.id === id ? { ...l, qty: Math.min(l.max, Math.max(1, n)) } : l));
    setLines(next);
    setStatus(`Subtotal ${money(next.reduce((s, l) => s + l.price * l.qty, 0))}`);
  }

  function remove(id: string) {
    const line = lines.find((l) => l.id === id);
    setLines(lines.filter((l) => l.id !== id));
    setStatus(`${line?.name} removed`);
  }

  function onKeyDown(l: Line, e: KeyboardEvent<HTMLInputElement>) {
    const keys: Record<string, number> = { ArrowUp: l.qty + 1, ArrowDown: l.qty - 1, Home: 1, End: l.max };
    if (!(e.key in keys)) return;
    e.preventDefault();
    setQty(l.id, keys[e.key]);
  }

  const subtotal = lines.reduce((s, l) => s + l.price * l.qty, 0);
  const step = "grid h-10 w-9 place-items-center text-stone-700 hover:bg-stone-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-700 disabled:text-stone-300";

  return (
    <div className="w-full max-w-sm">
      <ul className="divide-y divide-stone-200 border-y border-stone-200">
        {lines.map((l) => (
          <li key={l.id} className="flex flex-wrap items-center gap-x-3 gap-y-2 py-3">
            <div className="min-w-0 basis-full">
              <p id={`line-${l.id}`} className="truncate text-sm font-semibold text-stone-900">
                {l.name}
              </p>
              <p className="text-xs text-stone-600">
                {l.variant} · {money(l.price)}
              </p>
            </div>
            <div className="flex items-center rounded-md border border-stone-300">
              <button type="button" aria-label={`Fewer ${l.name}`} disabled={l.qty <= 1} onClick={() => setQty(l.id, l.qty - 1)} className={step}>
                <svg viewBox="0 0 20 20" aria-hidden="true" className="h-3.5 w-3.5 fill-none stroke-current" strokeWidth={2} strokeLinecap="round">
                  <path d="M5 10h10" />
                </svg>
              </button>
              <input
                type="text"
                inputMode="numeric"
                role="spinbutton"
                aria-labelledby={`line-${l.id}`}
                aria-valuenow={l.qty}
                aria-valuemin={1}
                aria-valuemax={l.max}
                value={l.qty}
                onChange={(e) => setQty(l.id, Number(e.target.value.replace(/\D/g, "")) || 1)}
                onKeyDown={(e) => onKeyDown(l, e)}
                className="h-10 w-9 border-x border-stone-300 text-center text-base font-semibold tabular-nums text-stone-900 outline-none focus-visible:bg-emerald-50"
              />
              <button type="button" aria-label={`More ${l.name}`} disabled={l.qty >= l.max} onClick={() => setQty(l.id, l.qty + 1)} className={step}>
                <svg viewBox="0 0 20 20" aria-hidden="true" className="h-3.5 w-3.5 fill-none stroke-current" strokeWidth={2} strokeLinecap="round">
                  <path d="M5 10h10M10 5v10" />
                </svg>
              </button>
            </div>
            <span className="ml-auto w-16 text-right text-sm font-semibold tabular-nums text-stone-900">{money(l.price * l.qty)}</span>
            <button
              type="button"
              aria-label={`Remove ${l.name}`}
              onClick={() => remove(l.id)}
              className="grid h-10 w-10 place-items-center rounded-md text-stone-500 hover:bg-stone-100 hover:text-stone-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700"
            >
              <svg viewBox="0 0 20 20" aria-hidden="true" className="h-4 w-4 fill-none stroke-current" strokeWidth={1.8} strokeLinecap="round">
                <path d="M6 6l8 8M14 6l-8 8" />
              </svg>
            </button>
          </li>
        ))}
        {lines.length === 0 && (
          <li className="flex items-center justify-between py-4 text-sm text-stone-600">
            Your cart is empty.
            <button type="button" onClick={() => setLines(START)} className="h-10 rounded-md px-3 font-medium text-emerald-800 hover:bg-emerald-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700">
              Restore items
            </button>
          </li>
        )}
      </ul>
      <div className="mt-3 flex items-center justify-between text-sm">
        <span className="text-stone-600">Subtotal</span>
        <span className="font-semibold tabular-nums text-stone-900">{money(subtotal)}</span>
      </div>
      <p role="status" className="sr-only">
        {status}
      </p>
    </div>
  );
}
  return CartLineQuantity;
})();
const q6React = "import { useState, type KeyboardEvent } from \"react\";\n\ntype Line = { id: string; name: string; variant: string; price: number; qty: number; max: number };\n\nconst START: Line[] = [\n  { id: \"apron\", name: \"Linen apron\", variant: \"Sand, one size\", price: 28, qty: 1, max: 5 },\n  { id: \"wraps\", name: \"Beeswax wraps\", variant: \"Set of 3\", price: 16, qty: 2, max: 10 },\n];\n\nconst money = (n: number) => `$${n.toFixed(2)}`;\n\nexport default function CartLineQuantity() {\n  const [lines, setLines] = useState<Line[]>(START);\n  const [status, setStatus] = useState(\"\");\n\n  function setQty(id: string, n: number) {\n    const next = lines.map((l) => (l.id === id ? { ...l, qty: Math.min(l.max, Math.max(1, n)) } : l));\n    setLines(next);\n    setStatus(`Subtotal ${money(next.reduce((s, l) => s + l.price * l.qty, 0))}`);\n  }\n\n  function remove(id: string) {\n    const line = lines.find((l) => l.id === id);\n    setLines(lines.filter((l) => l.id !== id));\n    setStatus(`${line?.name} removed`);\n  }\n\n  function onKeyDown(l: Line, e: KeyboardEvent<HTMLInputElement>) {\n    const keys: Record<string, number> = { ArrowUp: l.qty + 1, ArrowDown: l.qty - 1, Home: 1, End: l.max };\n    if (!(e.key in keys)) return;\n    e.preventDefault();\n    setQty(l.id, keys[e.key]);\n  }\n\n  const subtotal = lines.reduce((s, l) => s + l.price * l.qty, 0);\n  const step = \"grid h-10 w-9 place-items-center text-stone-700 hover:bg-stone-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-emerald-700 disabled:text-stone-300\";\n\n  return (\n    <div className=\"w-full max-w-sm\">\n      <ul className=\"divide-y divide-stone-200 border-y border-stone-200\">\n        {lines.map((l) => (\n          <li key={l.id} className=\"flex flex-wrap items-center gap-x-3 gap-y-2 py-3\">\n            <div className=\"min-w-0 basis-full\">\n              <p id={`line-${l.id}`} className=\"truncate text-sm font-semibold text-stone-900\">\n                {l.name}\n              </p>\n              <p className=\"text-xs text-stone-600\">\n                {l.variant} · {money(l.price)}\n              </p>\n            </div>\n            <div className=\"flex items-center rounded-md border border-stone-300\">\n              <button type=\"button\" aria-label={`Fewer ${l.name}`} disabled={l.qty <= 1} onClick={() => setQty(l.id, l.qty - 1)} className={step}>\n                <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\" className=\"h-3.5 w-3.5 fill-none stroke-current\" strokeWidth={2} strokeLinecap=\"round\">\n                  <path d=\"M5 10h10\" />\n                </svg>\n              </button>\n              <input\n                type=\"text\"\n                inputMode=\"numeric\"\n                role=\"spinbutton\"\n                aria-labelledby={`line-${l.id}`}\n                aria-valuenow={l.qty}\n                aria-valuemin={1}\n                aria-valuemax={l.max}\n                value={l.qty}\n                onChange={(e) => setQty(l.id, Number(e.target.value.replace(/\\D/g, \"\")) || 1)}\n                onKeyDown={(e) => onKeyDown(l, e)}\n                className=\"h-10 w-9 border-x border-stone-300 text-center text-base font-semibold tabular-nums text-stone-900 outline-none focus-visible:bg-emerald-50\"\n              />\n              <button type=\"button\" aria-label={`More ${l.name}`} disabled={l.qty >= l.max} onClick={() => setQty(l.id, l.qty + 1)} className={step}>\n                <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\" className=\"h-3.5 w-3.5 fill-none stroke-current\" strokeWidth={2} strokeLinecap=\"round\">\n                  <path d=\"M5 10h10M10 5v10\" />\n                </svg>\n              </button>\n            </div>\n            <span className=\"ml-auto w-16 text-right text-sm font-semibold tabular-nums text-stone-900\">{money(l.price * l.qty)}</span>\n            <button\n              type=\"button\"\n              aria-label={`Remove ${l.name}`}\n              onClick={() => remove(l.id)}\n              className=\"grid h-10 w-10 place-items-center rounded-md text-stone-500 hover:bg-stone-100 hover:text-stone-900 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700\"\n            >\n              <svg viewBox=\"0 0 20 20\" aria-hidden=\"true\" className=\"h-4 w-4 fill-none stroke-current\" strokeWidth={1.8} strokeLinecap=\"round\">\n                <path d=\"M6 6l8 8M14 6l-8 8\" />\n              </svg>\n            </button>\n          </li>\n        ))}\n        {lines.length === 0 && (\n          <li className=\"flex items-center justify-between py-4 text-sm text-stone-600\">\n            Your cart is empty.\n            <button type=\"button\" onClick={() => setLines(START)} className=\"h-10 rounded-md px-3 font-medium text-emerald-800 hover:bg-emerald-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700\">\n              Restore items\n            </button>\n          </li>\n        )}\n      </ul>\n      <div className=\"mt-3 flex items-center justify-between text-sm\">\n        <span className=\"text-stone-600\">Subtotal</span>\n        <span className=\"font-semibold tabular-nums text-stone-900\">{money(subtotal)}</span>\n      </div>\n      <p role=\"status\" className=\"sr-only\">\n        {status}\n      </p>\n    </div>\n  );\n}\n";

const q6Html = `<div class="cart">
  <ul class="cart-lines">
    <li class="cart-line" data-price="28" data-max="5">
      <div class="cart-info">
        <p id="line-apron" class="cart-name">Linen apron</p>
        <p class="cart-meta">Sand, one size · $28.00</p>
      </div>
      <div class="cart-step">
        <button type="button" data-step="-1" aria-label="Fewer Linen apron">&minus;</button>
        <input type="text" inputmode="numeric" role="spinbutton" value="1" aria-labelledby="line-apron"
          aria-valuenow="1" aria-valuemin="1" aria-valuemax="5">
        <button type="button" data-step="1" aria-label="More Linen apron">+</button>
      </div>
      <span class="cart-total">$28.00</span>
      <button type="button" class="cart-remove" aria-label="Remove Linen apron">&times;</button>
    </li>
    <li class="cart-line" data-price="16" data-max="10">
      <div class="cart-info">
        <p id="line-wraps" class="cart-name">Beeswax wraps</p>
        <p class="cart-meta">Set of 3 · $16.00</p>
      </div>
      <div class="cart-step">
        <button type="button" data-step="-1" aria-label="Fewer Beeswax wraps">&minus;</button>
        <input type="text" inputmode="numeric" role="spinbutton" value="2" aria-labelledby="line-wraps"
          aria-valuenow="2" aria-valuemin="1" aria-valuemax="10">
        <button type="button" data-step="1" aria-label="More Beeswax wraps">+</button>
      </div>
      <span class="cart-total">$32.00</span>
      <button type="button" class="cart-remove" aria-label="Remove Beeswax wraps">&times;</button>
    </li>
  </ul>
  <div class="cart-sub"><span>Subtotal</span><strong class="cart-subtotal">$60.00</strong></div>
  <p class="cart-sr" role="status"></p>
</div>

<style>
  .cart { max-width: 24rem; font-family: system-ui, sans-serif; color: #1c1917; }
  .cart-lines { list-style: none; margin: 0; padding: 0; border-block: 1px solid #e7e5e4; }
  .cart-line { display: flex; flex-wrap: wrap; align-items: center; gap: 8px 12px; padding: 12px 0; }
  .cart-line + .cart-line { border-top: 1px solid #e7e5e4; }
  .cart-info { flex-basis: 100%; min-width: 0; }
  .cart-name { margin: 0; font-size: 14px; font-weight: 600; }
  .cart-meta { margin: 0; font-size: 12px; color: #57534e; }
  .cart-step { display: flex; border: 1px solid #d6d3d1; border-radius: 6px; }
  .cart-step button { width: 36px; height: 40px; border: 0; background: none; font-size: 16px; color: #44403c; cursor: pointer; }
  .cart-step button:disabled { color: #d6d3d1; cursor: not-allowed; }
  .cart-step input { width: 36px; border: 0; border-inline: 1px solid #d6d3d1; text-align: center;
    font: 600 16px system-ui, sans-serif; font-variant-numeric: tabular-nums; outline: none; }
  .cart-step input:focus-visible { background: #ecfdf5; }
  .cart-total { margin-left: auto; width: 64px; text-align: right; font-size: 14px; font-weight: 600; font-variant-numeric: tabular-nums; }
  .cart-remove { width: 40px; height: 40px; border: 0; border-radius: 6px; background: none; font-size: 18px; color: #78716c; cursor: pointer; }
  .cart-remove:hover { background: #f5f5f4; color: #1c1917; }
  .cart button:focus-visible { outline: 2px solid #047857; outline-offset: -2px; }
  .cart-sub { display: flex; justify-content: space-between; margin-top: 12px; font-size: 14px; color: #57534e; }
  .cart-sub strong { color: #1c1917; font-variant-numeric: tabular-nums; }
  .cart-sr { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
</style>

<script>
  (() => {
    const cart = document.querySelector(".cart");
    const status = cart.querySelector(".cart-sr");
    const money = (n) => \`$\${n.toFixed(2)}\`;

    function refresh() {
      let sub = 0;
      cart.querySelectorAll(".cart-line").forEach((line) => {
        const input = line.querySelector("input");
        const qty = Number(input.value);
        const total = Number(line.dataset.price) * qty;
        sub += total;
        line.querySelector(".cart-total").textContent = money(total);
        input.setAttribute("aria-valuenow", qty);
        const [dec, inc] = line.querySelectorAll(".cart-step button");
        dec.disabled = qty <= 1;
        inc.disabled = qty >= Number(line.dataset.max);
      });
      cart.querySelector(".cart-subtotal").textContent = money(sub);
      return sub;
    }

    cart.querySelectorAll(".cart-line").forEach((line) => {
      const input = line.querySelector("input");
      const max = Number(line.dataset.max);
      const set = (n) => {
        input.value = Math.min(max, Math.max(1, Math.round(n) || 1));
        status.textContent = \`Subtotal \${money(refresh())}\`;
      };
      line.querySelectorAll("[data-step]").forEach((b) =>
        b.addEventListener("click", () => set(Number(input.value) + Number(b.dataset.step)))
      );
      input.addEventListener("input", () => set(Number(input.value.replace(/\\D/g, ""))));
      input.addEventListener("keydown", (e) => {
        const v = Number(input.value);
        const keys = { ArrowUp: v + 1, ArrowDown: v - 1, Home: 1, End: max };
        if (e.key in keys) { e.preventDefault(); set(keys[e.key]); }
      });
      line.querySelector(".cart-remove").addEventListener("click", () => {
        const name = line.querySelector(".cart-name").textContent;
        line.remove();
        refresh();
        status.textContent = \`\${name} removed\`;
      });
    });
    refresh();
  })();
</script>
`;

const PIECES: Piece[] = [
  { id: "q1", name: "Classic bordered stepper", blurb: "Text field with spinbutton keys, a typed value that clamps on blur and buttons that stay focusable at the limits.", group: "fields", Demo: q1Demo, react: q1React, html: q1Html, stage: "bg-[#f7f5f1] ring-1 ring-inset ring-stone-200/80" },
  { id: "q3", name: "Step of six, rounded to whole cases", blurb: "Typed amounts snap to the nearest case and say so in a status line.", group: "fields", Demo: q3Demo, react: q3React, stage: "bg-[#f7f5f1] ring-1 ring-inset ring-stone-200/80" },
  { id: "q5", name: "Decimal weight with stacked arrows", blurb: "Quarter-kilo steps counted as whole numbers, with a running price.", group: "fields", Demo: q5Demo, react: q5React, stage: "bg-[#f7f5f1] ring-1 ring-inset ring-stone-200/80" },
  { id: "q2", name: "Pill stepper on a product card", blurb: "Compact dark control where the number itself is the focusable spinbutton.", group: "shop", Demo: q2Demo, react: q2React, stage: "bg-[#f7f5f1] ring-1 ring-inset ring-stone-200/80" },
  { id: "q4", name: "Add to cart that turns into a stepper", blurb: "The minus becomes a bin at one, and focus moves with the swap.", group: "shop", Demo: q4Demo, react: q4React, stage: "bg-[#f7f5f1] ring-1 ring-inset ring-stone-200/80" },
  { id: "q6", name: "Cart lines with totals", blurb: "Per-line limits, line totals, remove buttons and an announced subtotal.", group: "shop", Demo: q6Demo, react: q6React, html: q6Html, stage: "bg-white ring-1 ring-inset ring-stone-200/80" }
];
const GROUPS: { key: string; label: string }[] = [{"key":"fields","label":"Stepper fields"},{"key":"shop","label":"Product and cart"}];
const TEXT: { eyebrow: string; title: string; lead: string; refTitle: string; refNote: string } = {"eyebrow":"React · HTML, CSS and JS · spinbutton keys","title":"Quantity inputs for online stores","lead":"Six plus and minus steppers built for checkout pages: limits that disable the right button, step sizes such as cases of six, decimal weights, an add to cart button that becomes a stepper and a full cart line with live totals.","refTitle":"Spinbutton keyboard and ARIA reference","refNote":"Keys and attributes follow the spinbutton pattern in the WAI-ARIA Authoring Practices Guide. Page Up and Page Down are optional in that pattern; two pieces here use them for jumps of five."};
const REF_HEAD: string[] = ["Key or attribute","Behaviour","Used in"];
const REF_ROWS: string[][] = [
  [
    "Up Arrow / Down Arrow",
    "Increase or decrease the value by one step.",
    "Every piece (the pill and cart button also accept Right and Left)."
  ],
  [
    "Home / End",
    "Jump to the minimum or the maximum, when one is set.",
    "Every piece with a field you can focus."
  ],
  [
    "Page Up / Page Down",
    "Optional larger step. Here, five units or one kilo.",
    "Classic stepper, decimal weight."
  ],
  [
    "role=\"spinbutton\"",
    "Tells assistive technology this control holds a number in a range that can be stepped.",
    "On the focusable value, whether an input or a div with tabindex=0."
  ],
  [
    "aria-valuenow / min / max",
    "The current value and its limits. Update aria-valuenow on every change.",
    "All six."
  ],
  [
    "aria-valuetext",
    "A readable version when the bare number is unclear, such as '12 bottles, 2 cases' or '1.25 kilograms'.",
    "Case packs, decimal weight, pill card."
  ],
  [
    "Label",
    "The spinbutton needs a name: a label element, aria-label or aria-labelledby pointing at the product name.",
    "Cart line uses the product name as the label."
  ]
];

const COPY_ICON = "M9 9h10v10H9zM5 15V5h10";
const CHECK_ICON = "M5 12.5l4.5 4.5L19 7.5";
const CODE_ICON = "M8 7l-5 5 5 5M16 7l5 5-5 5";

export default function QuantityInputCollection() {
  const [group, setGroup] = useState("all");
  const [langs, setLangs] = useState<Record<string, Lang>>({});
  const [open, setOpen] = useState<string | null>(null);
  const [copied, setCopied] = useState<string | null>(null);
  const [announce, setAnnounce] = useState("");
  const timer = useRef<ReturnType<typeof setTimeout> | null>(null);

  useEffect(
    () => () => {
      if (timer.current) clearTimeout(timer.current);
    },
    []
  );

  const shown = group === "all" ? PIECES : PIECES.filter((p) => p.group === group);
  const langOf = (p: Piece): Lang => (p.html ? langs[p.id] ?? "react" : "react");
  const codeOf = (p: Piece) => (langOf(p) === "html" && p.html ? p.html : p.react);
  const langName = (l: Lang) => (l === "html" ? "HTML, CSS and JS" : "React and Tailwind");

  async function copy(p: Piece) {
    let ok = false;
    try {
      await navigator.clipboard.writeText(codeOf(p));
      ok = true;
    } catch {
      ok = false;
    }
    if (!ok) setOpen(p.id);
    setCopied(ok ? p.id : null);
    setAnnounce(ok ? `${p.name}: ${langName(langOf(p))} copied` : "Copy was blocked, so the code is shown under the demo for you to select");
    if (timer.current) clearTimeout(timer.current);
    timer.current = setTimeout(() => setCopied(null), 1600);
  }

  const ring = "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-emerald-700 focus-visible:ring-offset-2";
  const chip = `inline-flex min-h-[40px] items-center gap-1.5 whitespace-nowrap rounded-full px-4 text-[13px] font-medium transition-colors ${ring}`;

  return (
    <section
      className="bg-[#fbfaf8] px-4 py-12 sm:px-8 sm:py-14"
      style={{ fontFamily: 'Inter, ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif' }}
    >
      <div className="mx-auto max-w-6xl">
        <header className="max-w-3xl">
          <p className="font-mono text-[12px] uppercase tracking-[0.18em] text-stone-600">{TEXT.eyebrow}</p>
          <h2 className="mt-3 text-[32px] font-semibold leading-[1.08] tracking-[-0.03em] text-stone-950 sm:text-[42px]">{TEXT.title}</h2>
          <p className="mt-4 max-w-2xl text-[15px] leading-relaxed text-stone-600">{TEXT.lead}</p>
        </header>

        <div role="group" aria-label="Filter the collection" className="mt-8 flex flex-wrap gap-1.5 border-y border-stone-300/70 py-4">
          {[{ key: "all", label: "All" }, ...GROUPS, { key: "ref", label: "Reference table" }].map((g) => {
            const on = group === g.key;
            const n = g.key === "ref" ? REF_ROWS.length : g.key === "all" ? PIECES.length : PIECES.filter((p) => p.group === g.key).length;
            return (
              <button
                key={g.key}
                type="button"
                aria-pressed={on}
                onClick={() => setGroup(g.key)}
                className={`${chip} ${on ? "bg-emerald-800 text-white" : "bg-white text-stone-700 ring-1 ring-inset ring-stone-300 hover:bg-stone-100"}`}
              >
                {g.label}
                <span className={`font-mono text-[11px] ${on ? "opacity-75" : "text-stone-500"}`}>{n}</span>
              </button>
            );
          })}
        </div>

        <p className="sr-only" aria-live="polite">
          {announce}
        </p>

        {group !== "ref" && (
        <ul className="mt-6 grid grid-cols-1 items-start gap-4 md:grid-cols-2 lg:grid-cols-3">
          {shown.map((p) => {
            const isOpen = open === p.id;
            const l = langOf(p);
            return (
              <li
                key={p.id}
                className={`flex min-w-0 flex-col rounded-2xl border border-stone-200 bg-white shadow-[0_1px_2px_rgba(28,25,23,0.05)] ${isOpen ? "md:col-span-2 lg:col-span-3" : ""}`}
              >
                <div className="px-4 pt-4">
                  <h3 className="text-[14px] font-semibold text-stone-950">{p.name}</h3>
                  <p className="mt-0.5 text-[13px] leading-snug text-stone-600">{p.blurb}</p>
                </div>
                <div className={`relative mx-3 mt-3 flex min-w-0 items-start justify-center rounded-xl px-3 py-6 sm:px-5 ${p.stage}`}>
                  <p.Demo />
                </div>
                <div className="flex flex-wrap items-center gap-2 px-3 py-3">
                  {p.html && (
                    <div role="group" aria-label={`Code version for ${p.name}`} className="inline-flex rounded-lg bg-stone-100 p-0.5">
                      {(["react", "html"] as Lang[]).map((x) => (
                        <button
                          key={x}
                          type="button"
                          aria-pressed={l === x}
                          onClick={() => setLangs({ ...langs, [p.id]: x })}
                          className={`h-9 rounded-md px-2.5 text-[12px] font-semibold ${ring} ${l === x ? "bg-white text-stone-950 shadow-sm" : "text-stone-600 hover:text-stone-900"}`}
                        >
                          {x === "react" ? "React" : "HTML"}
                        </button>
                      ))}
                    </div>
                  )}
                  <span className="flex-1" />
                  <button
                    type="button"
                    aria-expanded={isOpen}
                    aria-controls={`code-${p.id}`}
                    onClick={() => setOpen(isOpen ? null : p.id)}
                    className={`inline-flex h-10 items-center gap-1.5 rounded-lg px-3 text-[12.5px] font-semibold text-stone-700 hover:bg-stone-100 ${ring}`}
                  >
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-4 w-4">
                      <path d={CODE_ICON} />
                    </svg>
                    {isOpen ? "Hide code" : "Code"}
                  </button>
                  <button
                    type="button"
                    onClick={() => copy(p)}
                    aria-label={`Copy ${langName(l)} for ${p.name}`}
                    className={`inline-flex h-10 items-center gap-1.5 rounded-lg px-3 text-[12.5px] font-semibold transition-colors ${ring} ${copied === p.id ? "bg-emerald-700 text-white" : "bg-stone-900 text-white hover:bg-stone-700"}`}
                  >
                    <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden="true" className="h-3.5 w-3.5">
                      <path d={copied === p.id ? CHECK_ICON : COPY_ICON} />
                    </svg>
                    {copied === p.id ? "Copied" : "Copy"}
                  </button>
                </div>
                {isOpen && (
                  <pre
                    id={`code-${p.id}`}
                    tabIndex={0}
                    aria-label={`${langName(l)} for ${p.name}`}
                    className="max-h-[440px] overflow-auto rounded-b-2xl border-t border-stone-800 bg-[#121316] p-4 font-mono text-[12px] leading-[1.65] text-stone-200"
                  >
                    <code>{codeOf(p)}</code>
                  </pre>
                )}
              </li>
            );
          })}
        </ul>
        )}

        {group === "ref" && (
        <div className="mt-6">
          <h3 className="text-[17px] font-semibold tracking-tight text-stone-950">{TEXT.refTitle}</h3>
          <p className="mt-1 max-w-3xl text-[13px] leading-relaxed text-stone-600">{TEXT.refNote}</p>
          <div className="mt-4 overflow-x-auto rounded-xl border border-stone-200 bg-white" tabIndex={0} role="region" aria-label={TEXT.refTitle}>
            <table className="w-full min-w-[600px] text-left text-[13px]">
              <thead className="bg-stone-50 text-stone-700">
                <tr>
                  {REF_HEAD.map((h) => (
                    <th key={h} scope="col" className="px-4 py-2.5 font-semibold">
                      {h}
                    </th>
                  ))}
                </tr>
              </thead>
              <tbody>
                {REF_ROWS.map((r, i) => (
                  <tr key={i} className="border-t border-stone-200 align-top">
                    {r.map((c, j) =>
                      j === 0 ? (
                        <th key={j} scope="row" className="whitespace-nowrap px-4 py-2.5 font-mono text-[12px] font-medium text-stone-900">
                          {c}
                        </th>
                      ) : (
                        <td key={j} className="px-4 py-2.5 leading-relaxed text-stone-700">
                          {c}
                        </td>
                      )
                    )}
                  </tr>
                ))}
              </tbody>
            </table>
          </div>
        </div>
        )}
      </div>
    </section>
  );
}
```
