{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"load-more","type":"registry:ui","title":"Load More","description":"Sentinel that loads before you hit the end.","dependencies":["motion"],"categories":["async"],"docs":"https://www.interior.dev/docs/load-more","files":[{"path":"registry/interior/load-more.tsx","type":"registry:ui","target":"components/interior/load-more.tsx","content":"\"use client\";\n\nimport { useCallback, useEffect, useRef, useState } from \"react\";\nimport type { ReactNode, RefObject } from \"react\";\nimport { motion, useReducedMotion } from \"motion/react\";\n\nconst CROSSFADE = { type: \"spring\", stiffness: 260, damping: 34, mass: 0.8 } as const;\nconst INSTANT = { duration: 0 } as const;\nconst SPIN = { duration: 0.7, ease: \"linear\", repeat: Infinity } as const;\n\nexport type LoadMoreStatus = \"idle\" | \"loading\" | \"error\" | \"end\";\n\nexport type UseLoadMoreOptions = {\n  onLoad: () => unknown;\n  hasMore?: boolean;\n  auto?: boolean;\n  rootRef?: RefObject<Element | null>;\n  rootMargin?: string;\n  maxAutoLoads?: number;\n  onError?: (error: unknown) => void;\n};\n\nexport type UseLoadMoreReturn = {\n  status: LoadMoreStatus;\n  paused: boolean;\n  sentinelRef: RefObject<HTMLDivElement | null>;\n  load: () => void;\n};\n\nexport function useLoadMore({\n  onLoad,\n  hasMore = true,\n  auto = true,\n  rootRef,\n  rootMargin = \"600px 0px\",\n  maxAutoLoads = 3,\n  onError,\n}: UseLoadMoreOptions): UseLoadMoreReturn {\n  const [phase, setPhase] = useState<\"idle\" | \"loading\" | \"error\">(\"idle\");\n  const [ended, setEnded] = useState(false);\n  const [paused, setPaused] = useState(false);\n\n  const sentinelRef = useRef<HTMLDivElement>(null);\n  const observer = useRef<IntersectionObserver | null>(null);\n  const seq = useRef(0);\n  const busy = useRef(false);\n  const alive = useRef(true);\n  const runs = useRef(0);\n  const done = useRef(false);\n  const blocked = useRef(false);\n\n  const fetchMore = useRef(onLoad);\n  fetchMore.current = onLoad;\n  const fail = useRef(onError);\n  fail.current = onError;\n  const more = useRef(hasMore);\n  more.current = hasMore;\n\n  const reobserve = useCallback(() => {\n    const io = observer.current;\n    const el = sentinelRef.current;\n    if (io && el) {\n      io.unobserve(el);\n      io.observe(el);\n    }\n  }, []);\n\n  const run = useCallback(\n    (manual: boolean) => {\n      if (busy.current || done.current || !more.current) return;\n\n      if (manual) {\n        runs.current = 0;\n        blocked.current = false;\n        setPaused(false);\n      } else {\n        if (blocked.current) return;\n        if (runs.current >= maxAutoLoads) {\n          setPaused(true);\n          return;\n        }\n        runs.current += 1;\n      }\n\n      busy.current = true;\n      const id = ++seq.current;\n      setPhase(\"loading\");\n\n      Promise.resolve()\n        .then(() => fetchMore.current())\n        .then(\n          (result) => {\n            busy.current = false;\n            if (!alive.current || id !== seq.current) return;\n            setPhase(\"idle\");\n            if (result === false) {\n              done.current = true;\n              setEnded(true);\n              return;\n            }\n            reobserve();\n          },\n          (error: unknown) => {\n            busy.current = false;\n            if (!alive.current || id !== seq.current) return;\n            blocked.current = true;\n            fail.current?.(error);\n            setPhase(\"error\");\n          },\n        );\n    },\n    [maxAutoLoads, reobserve],\n  );\n\n  useEffect(() => {\n    if (hasMore) {\n      done.current = false;\n      setEnded(false);\n    }\n  }, [hasMore]);\n\n  useEffect(() => {\n    alive.current = true;\n    return () => {\n      alive.current = false;\n    };\n  }, []);\n\n  useEffect(() => {\n    if (!auto || ended) return;\n    const el = sentinelRef.current;\n    if (!el || typeof IntersectionObserver === \"undefined\") return;\n\n    const io = new IntersectionObserver(\n      (entries) => {\n        const entry = entries[entries.length - 1];\n        if (!entry) return;\n        if (entry.isIntersecting) {\n          run(false);\n          return;\n        }\n        runs.current = 0;\n        setPaused(false);\n      },\n      { root: rootRef?.current ?? null, rootMargin, threshold: 0 },\n    );\n\n    observer.current = io;\n    io.observe(el);\n\n    return () => {\n      io.disconnect();\n      observer.current = null;\n    };\n  }, [auto, ended, rootMargin, rootRef, run]);\n\n  const load = useCallback(() => run(true), [run]);\n\n  const status: LoadMoreStatus = ended || !hasMore ? \"end\" : phase;\n\n  return { status, paused, sentinelRef, load };\n}\n\nfunction ChevronMark() {\n  return (\n    <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\" aria-hidden=\"true\" className=\"shrink-0\">\n      <path\n        d=\"M2.6 4.2 5.5 7.1 8.4 4.2\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.6\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction CheckMark() {\n  return (\n    <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\" aria-hidden=\"true\" className=\"shrink-0\">\n      <path\n        d=\"M2.2 5.7 4.5 8 8.8 3\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.6\"\n        strokeLinecap=\"round\"\n        strokeLinejoin=\"round\"\n      />\n    </svg>\n  );\n}\n\nfunction AlertMark() {\n  return (\n    <svg width=\"11\" height=\"11\" viewBox=\"0 0 11 11\" fill=\"none\" aria-hidden=\"true\" className=\"shrink-0\">\n      <path d=\"M5.5 2.4v3.4\" stroke=\"currentColor\" strokeWidth=\"1.6\" strokeLinecap=\"round\" />\n      <rect x=\"4.7\" y=\"7.5\" width=\"1.6\" height=\"1.6\" rx=\"0.4\" fill=\"currentColor\" />\n    </svg>\n  );\n}\n\nfunction SpinnerMark({ spinning }: { spinning: boolean }) {\n  return (\n    <motion.svg\n      width=\"11\"\n      height=\"11\"\n      viewBox=\"0 0 11 11\"\n      fill=\"none\"\n      aria-hidden=\"true\"\n      className=\"shrink-0\"\n      style={{ transformOrigin: \"50% 50%\" }}\n      initial={false}\n      animate={spinning ? { rotate: 360 } : { rotate: 0 }}\n      transition={spinning ? SPIN : INSTANT}\n    >\n      <circle cx=\"5.5\" cy=\"5.5\" r=\"3.9\" stroke=\"currentColor\" strokeWidth=\"1.5\" opacity=\"0.25\" />\n      <path\n        d=\"M5.5 1.6a3.9 3.9 0 0 1 3.9 3.9\"\n        stroke=\"currentColor\"\n        strokeWidth=\"1.5\"\n        strokeLinecap=\"round\"\n      />\n    </motion.svg>\n  );\n}\n\nexport type LoadMoreLabels = Record<LoadMoreStatus, string>;\n\nconst DEFAULT_LABELS: LoadMoreLabels = {\n  idle: \"Load more\",\n  loading: \"Loading\",\n  error: \"Couldn’t load. Try again\",\n  end: \"You’re all caught up\",\n};\n\nconst ORDER: LoadMoreStatus[] = [\"idle\", \"loading\", \"error\", \"end\"];\n\nconst TONE: Record<LoadMoreStatus, string> = {\n  idle: \"text-stone-700 dark:text-stone-200\",\n  loading: \"text-stone-500 dark:text-stone-400\",\n  error: \"text-red-600 dark:text-red-400\",\n  end: \"text-stone-500 dark:text-stone-400\",\n};\n\nexport type LoadMoreProps = {\n  onLoad: () => unknown;\n  hasMore?: boolean;\n  auto?: boolean;\n  rootRef?: RefObject<Element | null>;\n  rootMargin?: string;\n  maxAutoLoads?: number;\n  labels?: Partial<LoadMoreLabels>;\n  onError?: (error: unknown) => void;\n  className?: string;\n};\n\nexport function LoadMore({\n  onLoad,\n  hasMore = true,\n  auto = true,\n  rootRef,\n  rootMargin = \"600px 0px\",\n  maxAutoLoads = 3,\n  labels,\n  onError,\n  className = \"\",\n}: LoadMoreProps) {\n  const reduced = useReducedMotion();\n\n  const { status, sentinelRef, load } = useLoadMore({\n    onLoad,\n    hasMore,\n    auto,\n    rootRef,\n    rootMargin,\n    maxAutoLoads,\n    onError,\n  });\n\n  const fade = reduced ? INSTANT : CROSSFADE;\n\n  const text: LoadMoreLabels = { ...DEFAULT_LABELS, ...labels };\n  const icons: Record<LoadMoreStatus, ReactNode> = {\n    idle: <ChevronMark />,\n    loading: <SpinnerMark spinning={status === \"loading\" && !reduced} />,\n    error: <AlertMark />,\n    end: <CheckMark />,\n  };\n\n  const inert = status === \"loading\" || status === \"end\";\n\n  return (\n    <div className={`relative flex w-full justify-center ${className}`}>\n      <div\n        ref={sentinelRef}\n        aria-hidden\n        className=\"pointer-events-none absolute inset-x-0 top-0 h-px\"\n      />\n\n      <button\n        type=\"button\"\n        aria-busy={status === \"loading\" || undefined}\n        aria-disabled={inert || undefined}\n        aria-label={text[status]}\n        onClick={(event) => {\n          if (inert) {\n            event.preventDefault();\n            return;\n          }\n          load();\n        }}\n        className={`group relative inline-flex h-8 select-none items-center justify-center rounded-[9px] px-3 text-[12.5px] font-medium outline-none transition-[background-color,box-shadow,transform] duration-150 focus-visible:bg-[#4568FF]/[0.06] focus-visible:shadow-[inset_0_0_0_1px_#4568FF] dark:focus-visible:bg-[#93B0FF]/[0.1] dark:focus-visible:shadow-[inset_0_0_0_1px_#93B0FF] ${\n          inert\n            ? \"cursor-default\"\n            : \"cursor-pointer hover:bg-stone-800/[0.04] active:translate-y-px dark:hover:bg-white/[0.06]\"\n        }`}\n        style={{ touchAction: \"manipulation\" }}\n      >\n        <motion.span\n          aria-hidden\n          initial={false}\n          animate={{ y: status === \"loading\" ? 1 : 0 }}\n          transition={reduced ? INSTANT : CROSSFADE}\n          className=\"relative grid place-items-center\"\n        >\n          {ORDER.map((s) => (\n            <motion.span\n              key={s}\n              initial={false}\n              animate={\n                s === status\n                  ? { opacity: 1, y: 0, filter: \"blur(0px)\" }\n                  : { opacity: 0, y: 3, filter: \"blur(3px)\" }\n              }\n              transition={fade}\n              className={`col-start-1 row-start-1 flex items-center gap-1.5 whitespace-nowrap ${TONE[s]}`}\n            >\n              {icons[s]}\n              {text[s]}\n            </motion.span>\n          ))}\n        </motion.span>\n      </button>\n\n      <span role=\"status\" aria-live=\"polite\" aria-atomic=\"true\" className=\"sr-only\">\n        {status === \"error\" || status === \"end\" ? text[status] : \"\"}\n      </span>\n    </div>\n  );\n}\n"}]}