{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "load-more",
  "title": "Load More",
  "author": "ecomcn <hello@ecomcn.dev>",
  "description": "Progressive loading for a product list: a progress rule and “Showing 24 of 312” count, a Load more button, optional hybrid or infinite auto-loading, focus moved to the first new item, and an announced count. The pattern Baymard found performs best, in one component.",
  "registryDependencies": [
    "button"
  ],
  "files": [
    {
      "path": "src/registry/ecomcn/load-more/load-more.tsx",
      "content": "\"use client\"\n\nimport * as React from \"react\"\n\nimport { Button } from \"@/components/ui/button\"\nimport { cn } from \"@/lib/utils\"\n\nexport interface LoadMoreProps extends Omit<React.HTMLAttributes<HTMLDivElement>, \"children\"> {\n  /** Items currently rendered. */\n  shown: number\n  /** Total results, if your backend knows it. Drives the count and the progress rule. */\n  total?: number\n  /** Defaults to `shown < total`. Pass it when `total` is unknown. */\n  hasMore?: boolean\n  loading?: boolean\n  onLoadMore: () => void | Promise<unknown>\n  /**\n   * `button` — only on click (the default).\n   * `hybrid` — the first page needs a click, later pages load as the shopper nears the end.\n   * `infinite` — always loads on approach. Keeps the footer out of reach; use sparingly.\n   */\n  mode?: \"button\" | \"hybrid\" | \"infinite\"\n  /** Shown with a retry button when the last load failed. */\n  error?: string | null\n  /**\n   * The id of the list this appends to (e.g. your ProductGrid). After a\n   * click, focus moves to the first new item, so a keyboard user carries on\n   * from where the new results start instead of back at the button.\n   */\n  controls?: string\n  label?: string\n  noun?: { one: string; other: string }\n  locale?: string\n  /** How early infinite modes start loading. */\n  rootMargin?: string\n}\n\nconst FOCUSABLE = 'a[href], button:not([disabled]), [tabindex]:not([tabindex=\"-1\"])'\n\nexport function LoadMore({\n  shown,\n  total,\n  hasMore: hasMoreProp,\n  loading = false,\n  onLoadMore,\n  mode = \"button\",\n  error,\n  controls,\n  label = \"Load more\",\n  noun = { one: \"product\", other: \"products\" },\n  locale,\n  rootMargin = \"600px 0px\",\n  className,\n  ...props\n}: LoadMoreProps) {\n  const hasMore = hasMoreProp ?? (total !== undefined ? shown < total : true)\n  const format = React.useMemo(() => new Intl.NumberFormat(locale), [locale])\n  const plural = React.useMemo(() => new Intl.PluralRules(locale), [locale])\n  const word = (n: number) => (plural.select(n) === \"one\" ? noun.one : noun.other)\n\n  const status =\n    total !== undefined\n      ? `Showing ${format.format(shown)} of ${format.format(total)} ${word(total)}`\n      : `Showing ${format.format(shown)} ${word(shown)}`\n\n  // Silent until the shopper has asked for more, so the page doesn't speak\n  // on load; afterwards the live region announces each new count once.\n  const [requested, setRequested] = React.useState(false)\n  // Hybrid mode arms itself on the first click — and disarms when the list\n  // starts over (a filter or sort change), so every new result set asks once.\n  const [armedAt, setArmedAt] = React.useState<number | null>(null)\n  const armed = armedAt !== null && shown >= armedAt\n  const auto = hasMore && !loading && !error && (mode === \"infinite\" || (mode === \"hybrid\" && armed))\n\n  // One request per page: if a load returns nothing new, don't hammer it.\n  const requestedAt = React.useRef<number | null>(null)\n  const focusFrom = React.useRef<number | null>(null)\n  const sentinel = React.useRef<HTMLDivElement>(null)\n\n  const load = React.useCallback(\n    (from: \"click\" | \"auto\") => {\n      if (from === \"auto\" && requestedAt.current === shown) return\n      requestedAt.current = shown\n      setRequested(true)\n      if (from === \"click\") {\n        focusFrom.current = shown\n        setArmedAt(shown)\n      }\n      void onLoadMore()\n    },\n    [onLoadMore, shown]\n  )\n\n  // Re-created whenever loading settles: a fresh observer reports the current\n  // intersection immediately, so a sentinel that is *still* on screen after a\n  // fast load triggers the next page instead of waiting for a scroll.\n  React.useEffect(() => {\n    const node = sentinel.current\n    if (!auto || !node || typeof IntersectionObserver === \"undefined\") return\n    const observer = new IntersectionObserver(\n      (entries) => {\n        if (entries.some((entry) => entry.isIntersecting)) load(\"auto\")\n      },\n      { rootMargin }\n    )\n    observer.observe(node)\n    return () => observer.disconnect()\n  }, [auto, load, rootMargin])\n\n  // After a click-triggered load lands, move focus to the first new item.\n  React.useEffect(() => {\n    const from = focusFrom.current\n    if (from === null || shown <= from || !controls) return\n    focusFrom.current = null\n    const item = document.getElementById(controls)?.children[from] as HTMLElement | undefined\n    if (!item) return\n    const target = item.matches(FOCUSABLE) ? item : item.querySelector<HTMLElement>(FOCUSABLE)\n    if (target) {\n      target.focus()\n    } else {\n      item.tabIndex = -1\n      item.focus()\n    }\n  }, [shown, controls])\n\n  const progress = total ? Math.min(1, shown / total) : undefined\n\n  return (\n    <div\n      ref={sentinel}\n      data-slot=\"load-more\"\n      className={cn(\"text-sm\", className)}\n      {...props}\n    >\n      {/* The rule is the progress bar: filled to the share already shown. */}\n      <div aria-hidden className=\"h-px w-full bg-border\">\n        {progress !== undefined ? (\n          <div\n            className=\"h-px bg-foreground transition-[width] duration-500 motion-reduce:transition-none\"\n            style={{ width: `${progress * 100}%` }}\n          />\n        ) : null}\n      </div>\n\n      <div className=\"flex flex-wrap items-center justify-between gap-x-6 gap-y-3 pt-4\">\n        <p className=\"text-muted-foreground tabular-nums\">{status}</p>\n\n        {error ? null : hasMore ? (\n          <Button\n            type=\"button\"\n            variant=\"outline\"\n            aria-controls={controls}\n            disabled={loading}\n            onClick={() => load(\"click\")}\n            className=\"w-full sm:w-auto\"\n          >\n            {loading ? \"Loading…\" : label}\n          </Button>\n        ) : (\n          <p className=\"text-muted-foreground\">\n            {total !== undefined\n              ? `That’s all ${format.format(total)} ${word(total)}.`\n              : \"That’s everything.\"}\n          </p>\n        )}\n      </div>\n\n      {error ? (\n        <div role=\"alert\" className=\"mt-3 flex flex-wrap items-center gap-3\">\n          <p>{error}</p>\n          <Button type=\"button\" variant=\"outline\" size=\"sm\" onClick={() => load(\"click\")}>\n            Try again\n          </Button>\n        </div>\n      ) : null}\n\n      <span className=\"sr-only\" role=\"status\" aria-live=\"polite\">\n        {requested && !loading ? status : \"\"}\n      </span>\n    </div>\n  )\n}\n",
      "type": "registry:component",
      "target": "components/ecomcn/load-more.tsx"
    }
  ],
  "categories": [
    "ecommerce",
    "catalog",
    "listing",
    "pagination"
  ],
  "type": "registry:component"
}