{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "state-view",
  "title": "State View",
  "author": "Ballmac <https://ui.ballmac.com>",
  "description": "Shows the right thing for what the data is doing: a skeleton while loading, a helpful empty state, an error with retry, or your content. Announced to screen readers, never steals focus, fades with the theme's motion.",
  "dependencies": [
    "lucide-react"
  ],
  "registryDependencies": [
    "utils",
    "https://ui.ballmac.com/r/button.json",
    "https://ui.ballmac.com/r/empty.json",
    "https://ui.ballmac.com/r/skeleton.json",
    "https://ui.ballmac.com/r/i18n.json",
    "https://ui.ballmac.com/r/use-resource.json",
    "https://ui.ballmac.com/r/frame-props.json"
  ],
  "files": [
    {
      "path": "registry/ballmac/components/state-view.tsx",
      "content": "// Ballmac UI: State View. https://ui.ballmac.com/components/state-view\n\"use client\"\n\nimport * as React from \"react\"\nimport { CircleAlert, Inbox, RotateCw } from \"lucide-react\"\n\nimport { Button } from \"@/components/ballmac/button\"\nimport { Empty, EmptyAction, EmptyDescription, EmptyMedia, EmptyTitle } from \"@/components/ballmac/empty\"\nimport { Skeleton } from \"@/components/ballmac/skeleton\"\nimport { useMessages } from \"@/lib/ballmac/i18n\"\nimport { frameProps } from \"@/lib/ballmac/frame-props\"\nimport { isBusy, type BlockState } from \"@/lib/ballmac/use-resource\"\nimport { cn } from \"@/lib/utils\"\n\ntype EmptyConfig = {\n  title?: string\n  description?: string\n  icon?: React.ReactNode\n  /** The way out of the empty state: usually a button that creates the first item. */\n  action?: React.ReactNode\n}\n\ntype ErrorConfig = {\n  title?: string\n  description?: string\n  /** Technical detail for people who want it, shown behind a disclosure. */\n  details?: string\n  retryLabel?: string\n  /** Replace the retry button, for example with a link to status. */\n  action?: React.ReactNode\n}\n\ntype StateViewProps = Omit<React.ComponentProps<\"div\">, \"children\"> & {\n  /** What to show. `submitting` and `success` belong to forms and render the children. */\n  state: BlockState\n  /** Shown while loading. Make it the same shape as the ready content so nothing jumps. A plain set of lines is used if you leave it out. */\n  loading?: React.ReactNode\n  /** Copy and action for the empty state, or your own node. */\n  empty?: EmptyConfig | React.ReactNode\n  /** Copy for the error state, or your own node. */\n  error?: ErrorConfig | React.ReactNode\n  /** Called by the retry button. Without it the button is not drawn. */\n  onRetry?: () => void\n  /** Text announced to assistive technology while loading. */\n  label?: string\n  /** Drop the empty and error panels' border and tint, for use inside a frame that already has them. */\n  bare?: boolean\n  /** The ready content. */\n  children?: React.ReactNode\n}\n\n/** The props every block with states takes, so they all behave the same way. */\ntype StateProps = {\n  /** What the block shows. Defaults to `ready`. */\n  state?: BlockState\n  /** Called by the retry button in the error state. Without it the button is not drawn. */\n  onRetry?: () => void\n  /** Copy and action for the empty state. */\n  emptyState?: EmptyConfig\n  /** Copy for the error state. */\n  errorState?: ErrorConfig\n}\n\nconst isNode = (v: unknown): v is React.ReactElement => React.isValidElement(v)\n\n/** Placeholder lines for when no skeleton is given. */\nfunction DefaultSkeleton() {\n  return (\n    <div className=\"grid gap-3\" aria-hidden=\"true\">\n      <Skeleton className=\"h-5 w-1/3\" />\n      <Skeleton className=\"h-4 w-full\" />\n      <Skeleton className=\"h-4 w-11/12\" />\n      <Skeleton className=\"h-4 w-4/5\" />\n    </div>\n  )\n}\n\n/**\n * Shows the right thing for what the data is doing: a skeleton while loading, a helpful empty state, an error with a way\n * to try again, or your content when it is ready. It announces every change to screen readers, marks the region busy while\n * loading, fades between states with the theme's motion, and never moves focus on its own.\n */\nfunction StateView({ state, loading, empty, error, onRetry, label, bare = false, className, children, ...props }: StateViewProps) {\n  const msg = useMessages()\n  const e: EmptyConfig = isNode(empty) ? {} : ((empty as EmptyConfig | undefined) ?? {})\n  const x: ErrorConfig = isNode(error) ? {} : ((error as ErrorConfig | undefined) ?? {})\n  const view = state === \"loading\" || state === \"empty\" || state === \"error\" ? state : \"ready\"\n  return (\n    <div\n      data-slot=\"state-view\"\n      data-state={state}\n      aria-busy={isBusy(state) || undefined}\n      className={cn(\"min-w-0\", className)}\n      {...props}\n    >\n      {/* keyed by the view, so each change plays the enter animation; durations follow the theme's speed */}\n      <div\n        key={view}\n        className=\"animate-in fade-in-0 slide-in-from-bottom-1 h-full motion-reduce:animate-none [--tw-enter-translate-y:calc(var(--bm-distance,10px)*0.4)]\"\n      >\n        {view === \"loading\" ? (\n          <div role=\"status\" data-slot=\"state-view-loading\" className=\"h-full\">\n            <span className=\"sr-only\">{label ?? msg(\"state-view.loading\", \"Loading\")}</span>\n            {loading ?? <DefaultSkeleton />}\n          </div>\n        ) : view === \"empty\" ? (\n          isNode(empty) ? (\n            empty\n          ) : (\n            <Empty data-slot=\"state-view-empty\" className={cn(\"h-full bg-card/60\", bare && \"border-0 bg-transparent\")}>\n              <EmptyMedia>{e.icon ?? <Inbox />}</EmptyMedia>\n              <EmptyTitle>{e.title ?? msg(\"state-view.emptyTitle\", \"Nothing here yet\")}</EmptyTitle>\n              <EmptyDescription>{e.description ?? msg(\"state-view.emptyDescription\", \"When there is something to show, it will appear here.\")}</EmptyDescription>\n              {e.action ? <EmptyAction>{e.action}</EmptyAction> : null}\n            </Empty>\n          )\n        ) : view === \"error\" ? (\n          isNode(error) ? (\n            error\n          ) : (\n            <div\n              role=\"alert\"\n              data-slot=\"state-view-error\"\n              className={cn(\"border-destructive/25 bg-destructive/[0.04] flex h-full w-full min-w-0 flex-col items-center justify-center rounded-xl border px-5 py-10 text-center\", bare && \"border-0 bg-transparent\")}\n            >\n              <span aria-hidden=\"true\" className=\"bg-destructive/10 text-destructive mb-4 flex size-11 items-center justify-center rounded-full [&>svg]:size-5\">\n                <CircleAlert />\n              </span>\n              <h3 className=\"text-sm font-semibold\">{x.title ?? msg(\"state-view.errorTitle\", \"Something went wrong\")}</h3>\n              <p className=\"text-muted-foreground mt-1 max-w-sm text-sm leading-relaxed\">\n                {x.description ?? msg(\"state-view.errorDescription\", \"We could not load this. Check your connection and try again.\")}\n              </p>\n              {x.details ? (\n                <details className=\"text-muted-foreground mt-3 max-w-full text-xs\">\n                  <summary className=\"focus-visible:ring-[3px] focus-visible:ring-ring/50 cursor-default rounded-sm outline-none select-none\">{msg(\"state-view.details\", \"Details\")}</summary>\n                  <pre className=\"bg-muted mt-2 max-w-full overflow-auto rounded-md p-2 text-start font-mono whitespace-pre-wrap\">{x.details}</pre>\n                </details>\n              ) : null}\n              {x.action || onRetry ? (\n                <div className=\"mt-5 flex flex-wrap items-center justify-center gap-2\">\n                  {onRetry ? (\n                    <Button variant=\"outline\" size=\"sm\" onClick={onRetry}>\n                      <RotateCw aria-hidden=\"true\" />\n                      {x.retryLabel ?? msg(\"state-view.retry\", \"Try again\")}\n                    </Button>\n                  ) : null}\n                  {x.action}\n                </div>\n              ) : null}\n            </div>\n          )\n        ) : (\n          children\n        )}\n      </div>\n    </div>\n  )\n}\n\nexport { StateView, frameProps, type StateViewProps, type StateProps, type EmptyConfig, type ErrorConfig }\n",
      "type": "registry:ui",
      "target": "@components/ballmac/state-view.tsx"
    }
  ],
  "meta": {
    "schema": 1,
    "tier": "free",
    "tags": [
      "state",
      "loading",
      "empty",
      "error",
      "skeleton",
      "retry",
      "boundary",
      "async"
    ],
    "version": "1.0.1",
    "updated": "2026-10-12",
    "changelog": [
      {
        "version": "1.0.1",
        "date": "2026-10-12",
        "note": "Moves frameProps into its own frame-props module so server components can use it; state-view still exports it.",
        "breaking": false
      },
      {
        "version": "1.0.0",
        "date": "2026-10-12",
        "note": "First release.",
        "breaking": false
      }
    ],
    "tests": true,
    "ai": {
      "summary": "Wrap any data view: <StateView state={state} loading={<MySkeleton/>} empty={{ title, description, action }} error={{ title, description, details }} onRetry={retry}>{ready content}</StateView>. Pair with useResource, which returns the state. Loading is announced and the region is aria-busy; errors use role=alert; focus is never moved.",
      "whenToUse": [
        "Any list, table, feed or panel that loads data",
        "Giving a block its loading, empty and error views without writing them each time"
      ],
      "whenNotToUse": [
        "Forms: they track submitting and success themselves (blocks with a state prop handle it)",
        "A single value that is just a number: use a skeleton line"
      ],
      "composesWith": [
        "use-resource",
        "empty",
        "skeleton",
        "button"
      ],
      "a11y": [
        {
          "keys": "Screen reader",
          "action": "Loading is announced as a status; an error is announced as an alert; the region is aria-busy while loading"
        },
        {
          "keys": "Tab",
          "action": "Reaches the retry button and the details disclosure; focus is never moved for you"
        }
      ],
      "customization": [
        "loading: pass a skeleton shaped like your content so nothing jumps",
        "empty and error: a config object ({ title, description, icon, action } and { title, description, details, retryLabel, action }) or your own node",
        "onRetry: draws the retry button"
      ]
    },
    "examples": [
      "state-view-demo",
      "state-view-resource"
    ],
    "url": "https://ui.ballmac.com/components/state-view"
  },
  "categories": [
    "feedback"
  ],
  "type": "registry:ui"
}