{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "view-counter",
  "title": "View Counter",
  "description": "A view count from a fetcher you pass, with loading and failure states and no network code of its own.",
  "dependencies": [
    "lucide-react",
    "motion"
  ],
  "registryDependencies": [
    "utils",
    "https://miseui.junsu4621.workers.dev/r/motion.json"
  ],
  "files": [
    {
      "path": "registry/miseui/ui/view-counter.tsx",
      "content": "import type * as React from \"react\"\nimport { useEffect } from \"react\"\nimport { animate, motion, useMotionValue, useReducedMotion, useTransform } from \"motion/react\"\nimport { Eye } from \"lucide-react\"\nimport { SPRING_DEFAULT } from \"@/registry/miseui/lib/motion\"\nimport { defaultViewLabel, formatCount, useViewCount, type ViewCountFetcher } from \"@/registry/miseui/ui/view-counter-state\"\nimport { cn } from \"@/registry/miseui/lib/utils\"\n\ntype ViewCounterVariant = \"inline\" | \"badge\" | \"stat\"\ntype ViewCounterEffect = \"count\" | \"odometer\"\n\nconst COUNT_SECONDS = 1.2\nconst COUNT_EASE = [0.16, 1, 0.3, 1] as const\nconst DIGITS = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9] as const\nconst WHEEL_STAGGER_S = 0.04\n\nconst ROOT_CLASS: Record<ViewCounterVariant, string> = {\n  inline: \"inline-flex items-baseline gap-1 text-[13px] text-muted-foreground\",\n  badge: \"inline-flex min-h-7 items-center gap-1.5 rounded-full border bg-background px-3 text-[13px] text-foreground\",\n  stat: \"inline-grid gap-1 text-[13px] text-muted-foreground\",\n}\n\nconst NUMBER_CLASS: Record<ViewCounterVariant, string> = {\n  inline: \"tabular-nums\",\n  badge: \"font-medium tabular-nums\",\n  stat: \"text-3xl leading-tight font-semibold tracking-tight text-foreground tabular-nums\",\n}\n\ntype RootAttributes = Omit<React.ComponentProps<\"span\">, \"children\">\n\ninterface ViewCounterProps extends RootAttributes {\n  /** Yours: this component does no networking. Pass the signal on so an answer nobody is waiting for is cancelled. */\n  fetcher: ViewCountFetcher\n  /** Runs the fetcher again when it changes. A new fetcher function alone does not, so an inline arrow is safe. */\n  resourceKey?: string\n  /** `inline` for a metadata line, `badge` a pill with an eye, `stat` one large number. */\n  variant?: ViewCounterVariant\n  /** `count` rises from zero, `odometer` rolls each digit into place. */\n  effect?: ViewCounterEffect\n  /** Words after the number. The badge keeps them for screen readers, since the eye says the same. */\n  label?: string | ((count: number) => string)\n  /** Replaces the number formatting entirely. */\n  format?: (count: number) => string\n  /** For the number only. Defaults to the reader's language. */\n  locale?: string\n  /** 1.5K rather than 1,500. */\n  compact?: boolean\n  /** Text when the fetcher fails. Pass null with retryLabel null to show nothing at all. */\n  failureText?: React.ReactNode\n  retryLabel?: string | null\n  loadingLabel?: string\n  onError?: (error: unknown) => void\n}\n\nfunction CountUp({ value, text }: { value: number; text: (count: number) => string }) {\n  const progress = useMotionValue(0)\n  const shown = useTransform(progress, (current) => text(Math.round(current)))\n  useEffect(() => {\n    const controls = animate(progress, value, { duration: COUNT_SECONDS, ease: COUNT_EASE })\n    return () => controls.stop()\n  }, [progress, value])\n  return <motion.span>{shown}</motion.span>\n}\n\n/** Only digits roll: a separator or a compact suffix such as K or 천 just stands in its place. */\nfunction Odometer({ text }: { text: string }) {\n  const characters = [...text]\n  return (\n    <span data-slot=\"view-counter-odometer\" className=\"inline-flex\">\n      {characters.map((character, position) => {\n        const digit = Number(character)\n        if (character === \" \" || Number.isNaN(digit)) return <span key={position}>{character}</span>\n        return (\n          <span key={position} data-slot=\"view-counter-wheel\" className=\"inline-block h-[1lh] w-[1ch] overflow-hidden\">\n            <motion.span\n              className=\"block\"\n              initial={{ y: \"0%\" }}\n              animate={{ y: `${-digit * 10}%` }}\n              transition={{ ...SPRING_DEFAULT, delay: (characters.length - position) * WHEEL_STAGGER_S }}\n            >\n              {DIGITS.map((entry) => <span key={entry} className=\"block h-[1lh] text-center\">{entry}</span>)}\n            </motion.span>\n          </span>\n        )\n      })}\n    </span>\n  )\n}\n\nfunction CounterNumber({ value, effect, format }: { value: number; effect?: ViewCounterEffect; format: (count: number) => string }) {\n  const reduceMotion = useReducedMotion() ?? false\n  const text = format(value)\n  if (!effect || reduceMotion) return <span>{text}</span>\n  return (\n    <>\n      {/* The moving digits would be read out one frame at a time, so assistive tech gets the plain figure. */}\n      <span className=\"sr-only\">{text}</span>\n      <span aria-hidden=\"true\">{effect === \"odometer\" ? <Odometer text={text} /> : <CountUp value={value} text={format} />}</span>\n    </>\n  )\n}\n\nconst RETRY_CLASS =\n  \"inline-flex items-center rounded-sm font-medium text-foreground underline underline-offset-2 outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 pointer-coarse:min-h-10\"\n\nfunction CounterSkeleton({ label }: { label: string }) {\n  return (\n    <>\n      <span className=\"sr-only\">{label}</span>\n      <span aria-hidden=\"true\" data-slot=\"view-counter-skeleton\" className=\"inline-block h-[1lh] w-[4ch] animate-pulse rounded-sm bg-muted align-bottom motion-reduce:animate-none\" />\n    </>\n  )\n}\n\nfunction ViewCounter({\n  fetcher, resourceKey = \"\", variant = \"inline\", effect, label = defaultViewLabel, format, locale, compact = false,\n  failureText = \"Views unavailable\", retryLabel = \"Retry\", loadingLabel = \"Loading views\", onError, className, ...props\n}: ViewCounterProps) {\n  const { state, retry } = useViewCount(fetcher, resourceKey, onError)\n  const shared = { \"data-slot\": \"view-counter\", \"data-variant\": variant, \"data-effect\": effect, \"data-state\": state.status, ...props }\n  if (state.status === \"failed\") {\n    if (failureText === null && retryLabel === null) return null\n    return (\n      <span {...shared} className={cn(\"inline-flex flex-wrap items-center gap-x-2 text-[13px] text-muted-foreground\", className)}>\n        {failureText}\n        {retryLabel === null ? null : <button type=\"button\" onClick={retry} className={RETRY_CLASS}>{retryLabel}</button>}\n      </span>\n    )\n  }\n  const loading = state.status === \"loading\"\n  const formatter = format ?? ((count: number) => formatCount(count, locale, compact))\n  return (\n    <span {...shared} aria-busy={loading || undefined} className={cn(ROOT_CLASS[variant], className)}>\n      {variant === \"badge\" ? <Eye aria-hidden=\"true\" className=\"size-3.5 shrink-0 text-muted-foreground\" /> : null}\n      <span className={NUMBER_CLASS[variant]}>\n        {loading ? <CounterSkeleton label={loadingLabel} /> : <CounterNumber value={state.value} effect={effect} format={formatter} />}\n      </span>\n      {loading ? null : (\n        <span className={cn(variant === \"badge\" && \"sr-only\")}>{typeof label === \"string\" ? label : label(state.value)}</span>\n      )}\n    </span>\n  )\n}\n\nexport { ViewCounter, type ViewCounterProps, type ViewCounterVariant, type ViewCounterEffect, type ViewCountFetcher }\n",
      "type": "registry:ui"
    },
    {
      "path": "registry/miseui/ui/view-counter-state.ts",
      "content": "import { useEffect, useRef, useState } from \"react\"\n\nexport type ViewCountFetcher = (signal: AbortSignal) => Promise<number>\n\ntype CountState =\n  | { status: \"loading\" }\n  | { status: \"ready\"; value: number }\n  | { status: \"failed\" }\n\ninterface Settled {\n  key: string\n  attempt: number\n  state: CountState\n}\n\nconst LOADING: CountState = { status: \"loading\" }\n\n/** The answer comes from someone else's server, so its shape is checked before it reaches the screen. */\nexport function readCount(value: unknown) {\n  if (typeof value !== \"number\" || !Number.isFinite(value) || value < 0) throw new Error(\"View count must be a non-negative number\")\n  return value\n}\n\n/**\n * The fetcher is kept in a ref so an inline arrow function does not refetch on every parent render,\n * which on a counter that increments would count one reader many times. Only `resourceKey` and a retry run it again.\n */\nexport function useViewCount(fetcher: ViewCountFetcher, resourceKey: string, onError?: (error: unknown) => void) {\n  const fetcherRef = useRef(fetcher)\n  const onErrorRef = useRef(onError)\n  const [attempt, setAttempt] = useState(0)\n  const [settled, setSettled] = useState<Settled | null>(null)\n\n  useEffect(() => {\n    fetcherRef.current = fetcher\n    onErrorRef.current = onError\n  })\n\n  useEffect(() => {\n    const controller = new AbortController()\n    const { signal } = controller\n    // Started from a resolved promise so a fetcher that throws straight away lands in the same catch.\n    Promise.resolve()\n      .then(() => fetcherRef.current(signal))\n      .then((value) => {\n        if (!signal.aborted) setSettled({ key: resourceKey, attempt, state: { status: \"ready\", value: readCount(value) } })\n      })\n      .catch((error: unknown) => {\n        if (signal.aborted) return\n        setSettled({ key: resourceKey, attempt, state: { status: \"failed\" } })\n        onErrorRef.current?.(error)\n      })\n    return () => controller.abort()\n  }, [resourceKey, attempt])\n\n  // Derived rather than reset in the effect: an answer for an older key or attempt simply does not count.\n  const current = settled && settled.key === resourceKey && settled.attempt === attempt ? settled.state : LOADING\n  return { state: current, retry: () => setAttempt((count) => count + 1) }\n}\n\nexport function formatCount(value: number, locale: string | undefined, compact: boolean) {\n  return new Intl.NumberFormat(locale, compact ? { notation: \"compact\" } : undefined).format(value)\n}\n\nexport function defaultViewLabel(count: number) {\n  return count === 1 ? \"view\" : \"views\"\n}\n",
      "type": "registry:ui"
    }
  ],
  "categories": [
    "display"
  ],
  "type": "registry:ui"
}