{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "community-feed",
  "title": "Community Feed",
  "description": "Posts with authors, tags, reactions, filters, and numbered pagination.",
  "dependencies": [
    "class-variance-authority",
    "lucide-react"
  ],
  "registryDependencies": [
    "button",
    "toggle",
    "https://uaiblocks.dev/r/uai-theme.json",
    "https://uaiblocks.dev/r/author-card.json",
    "https://uaiblocks.dev/r/empty-state.json",
    "https://uaiblocks.dev/r/reaction-bar.json",
    "https://uaiblocks.dev/r/uai-utils.json"
  ],
  "files": [
    {
      "path": "src/registry/uai/blocks/community-feed.tsx",
      "content": "\"use client\";\n\nimport { cva } from \"class-variance-authority\";\nimport { ChevronLeft, ChevronRight } from \"lucide-react\";\nimport {\n  type ComponentProps,\n  createContext,\n  type KeyboardEvent,\n  useContext,\n  useId,\n  useState,\n} from \"react\";\nimport { Button } from \"@/components/ui/button\";\nimport { toggleVariants } from \"@/components/ui/toggle\";\nimport { AuthorCard, type AuthorCardProps } from \"@/components/ui/uai/author-card\";\nimport { EmptyState, type EmptyStateProps } from \"@/components/ui/uai/empty-state\";\nimport {\n  ReactionBar,\n  type ReactionBarProps,\n  type ReactionBarVariant,\n} from \"@/components/ui/uai/reaction-bar\";\nimport { cn } from \"@/lib/uai-utils\";\n\n/**\n * Single-choice segments are plain radio buttons styled like ToggleGroupItem: ToggleGroup's value\n * is a string on Radix but an array on Base UI, so the group's selection stays in uai state.\n */\nconst segmentClass = \"w-auto min-w-0 shrink-0 px-3 focus:z-10 focus-visible:z-10\";\n\nexport const COMMUNITY_FEED_VARIANTS = [\"list\", \"cards\", \"compact\"] as const;\nexport type CommunityFeedVariant = (typeof COMMUNITY_FEED_VARIANTS)[number];\nexport type CommunityFeedProps = ComponentProps<\"section\"> & {\n  variant?: CommunityFeedVariant;\n  /** The selected filter. The application filters the posts it renders. */\n  filter?: string;\n  defaultFilter?: string;\n  onFilterChange?: (filter: string) => void;\n  /** The current page, starting at 1. */\n  page?: number;\n  defaultPage?: number;\n  onPageChange?: (page: number) => void;\n};\n\ntype FeedContext = {\n  id: string;\n  variant: CommunityFeedVariant;\n  filter: string;\n  setFilter: (filter: string) => void;\n  page: number;\n  setPage: (page: number) => void;\n};\nconst Context = createContext<FeedContext | null>(null);\nfunction useFeed(part: string) {\n  const context = useContext(Context);\n  if (!context) throw new Error(`${part} must be used within CommunityFeed`);\n  return context;\n}\nconst PostContext = createContext<string | null>(null);\nfunction usePost(part: string) {\n  const id = useContext(PostContext);\n  if (!id) throw new Error(`${part} must be used within CommunityFeedPost`);\n  return id;\n}\n\nconst pill =\n  \"transition-[background-color,color,scale] duration-[120ms,120ms,140ms] ease-[ease-out,ease-out,cubic-bezier(0.23,1,0.32,1)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring enabled:active:scale-[0.97] motion-reduce:transition-none motion-reduce:enabled:active:scale-100\";\nconst pillIdle =\n  \"bg-transparent text-muted-foreground enabled:hover:bg-accent enabled:hover:text-foreground\";\nconst pillSelected = \"bg-accent text-foreground\";\n/** Replaces the primitive focus ring and size defaults with the Uai outline. */\nconst pillFocus =\n  \"focus-visible:ring-0 focus-visible:outline-solid [&_svg:not([class*='size-'])]:size-3.5\";\nconst feedLink =\n  \"no-underline transition-[color] duration-120 ease-out hover:text-foreground focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-ring motion-reduce:transition-none\";\n\nconst communityFeedVariants = cva(\"grid min-w-0 text-[13px]/[18px] text-foreground\", {\n  variants: {\n    variant: {\n      list: \"gap-5\",\n      cards: \"gap-5\",\n      compact: \"gap-3\",\n    },\n  },\n});\n\nconst reactionVariants: Record<CommunityFeedVariant, ReactionBarVariant> = {\n  list: \"pill\",\n  cards: \"outlined\",\n  compact: \"compact\",\n};\n\nfunction useControllable<T>(value: T | undefined, initial: T, onChange?: (value: T) => void) {\n  const [internal, setInternal] = useState(initial);\n  const current = value ?? internal;\n  const set = (next: T) => {\n    if (Object.is(next, current)) return;\n    if (value === undefined) setInternal(next);\n    onChange?.(next);\n  };\n  return [current, set] as const;\n}\n\n/** Community posts with authors, reactions, filters, and pagination. */\nexport function CommunityFeed({\n  variant = \"list\",\n  filter,\n  defaultFilter = \"\",\n  onFilterChange,\n  page,\n  defaultPage = 1,\n  onPageChange,\n  children,\n  className,\n  ...props\n}: CommunityFeedProps) {\n  const id = useId();\n  const [currentPage, setPage] = useControllable(page, defaultPage, onPageChange);\n  const [currentFilter, setFilter] = useControllable(filter, defaultFilter, (next: string) => {\n    onFilterChange?.(next);\n    setPage(1);\n  });\n  return (\n    <Context.Provider\n      value={{\n        id,\n        variant,\n        filter: currentFilter,\n        setFilter,\n        page: currentPage,\n        setPage,\n      }}\n    >\n      <section\n        aria-labelledby={`${id}-title`}\n        data-slot=\"community-feed\"\n        data-variant={variant}\n        className={cn(communityFeedVariants({ variant }), className)}\n        {...props}\n      >\n        {children}\n      </section>\n    </Context.Provider>\n  );\n}\n\nexport function CommunityFeedHeader({ className, ...props }: ComponentProps<\"header\">) {\n  useFeed(\"CommunityFeedHeader\");\n  return (\n    <header\n      data-slot=\"community-feed-header\"\n      className={cn(\"flex min-w-0 flex-wrap items-end justify-between gap-3\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function CommunityFeedTitle({ className, ...props }: ComponentProps<\"h2\">) {\n  const { id, variant } = useFeed(\"CommunityFeedTitle\");\n  return (\n    <h2\n      data-slot=\"community-feed-title\"\n      className={cn(\n        \"m-0 font-semibold tracking-[-0.015em]\",\n        variant === \"compact\" ? \"text-base/[22px]\" : \"text-xl/[26px]\",\n        className,\n      )}\n      {...props}\n      id={`${id}-title`}\n    />\n  );\n}\n\nexport function CommunityFeedDescription({ className, ...props }: ComponentProps<\"p\">) {\n  return (\n    <p\n      data-slot=\"community-feed-description\"\n      className={cn(\"m-0 text-pretty text-muted-foreground\", className)}\n      {...props}\n    />\n  );\n}\n\nexport type CommunityFeedFiltersProps = Omit<ComponentProps<\"div\">, \"defaultValue\" | \"dir\">;\n\n/** A radio group of feed filters. Arrow keys move the selection; changing it returns to page 1. */\nexport function CommunityFeedFilters({\n  \"aria-label\": label = \"Filter posts\",\n  onKeyDown,\n  className,\n  ...props\n}: CommunityFeedFiltersProps) {\n  useFeed(\"CommunityFeedFilters\");\n  return (\n    <div\n      role=\"radiogroup\"\n      aria-label={label}\n      data-slot=\"community-feed-filters\"\n      className={cn(\"flex w-auto flex-wrap items-center gap-1 rounded-none\", className)}\n      {...props}\n      onKeyDown={(event) => {\n        onKeyDown?.(event);\n        if (!event.defaultPrevented) moveRadio(event);\n      }}\n    />\n  );\n}\n\nexport type CommunityFeedFilterProps = Omit<ComponentProps<\"button\">, \"value\"> & {\n  value: string;\n};\n\nexport function CommunityFeedFilter({\n  value,\n  onClick,\n  className,\n  ...props\n}: CommunityFeedFilterProps) {\n  const context = useFeed(\"CommunityFeedFilter\");\n  const checked = context.filter === value;\n  return (\n    // biome-ignore lint/a11y/useSemanticElements: a segment button in a radio group; ToggleGroupItem renders the same role=\"radio\" button.\n    <button\n      type=\"button\"\n      role=\"radio\"\n      aria-checked={checked}\n      data-slot=\"community-feed-filter\"\n      data-state={checked ? \"on\" : \"off\"}\n      className={cn(\n        toggleVariants(),\n        segmentClass,\n        \"inline-flex min-w-0 cursor-pointer items-center gap-1.5 rounded-full border-0 px-3 text-[12.5px] font-medium whitespace-nowrap\",\n        context.variant === \"compact\" ? \"h-6\" : \"h-7\",\n        checked ? pillSelected : pillIdle,\n        \"hover:bg-accent hover:text-foreground data-[state=on]:text-foreground\",\n        pill,\n        pillFocus,\n        className,\n      )}\n      {...props}\n      tabIndex={checked ? 0 : -1}\n      onClick={(event) => {\n        onClick?.(event);\n        if (!event.defaultPrevented) context.setFilter(value);\n      }}\n    />\n  );\n}\n\nexport function CommunityFeedPosts({ className, ...props }: ComponentProps<\"ol\">) {\n  const { variant } = useFeed(\"CommunityFeedPosts\");\n  return (\n    <ol\n      data-slot=\"community-feed-posts\"\n      className={cn(\n        \"m-0 grid min-w-0 list-none p-0\",\n        variant === \"list\" && \"gap-0\",\n        variant === \"cards\" && \"grid-cols-[repeat(auto-fill,minmax(min(100%,280px),1fr))] gap-3\",\n        variant === \"compact\" && \"gap-1.5\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** One post, an article named by CommunityFeedPostTitle. */\nexport function CommunityFeedPost({ className, ...props }: ComponentProps<\"article\">) {\n  const { variant } = useFeed(\"CommunityFeedPost\");\n  const id = useId();\n  return (\n    <li className=\"grid min-w-0 [&:nth-child(2)>*]:[animation-delay:40ms] [&:nth-child(3)>*]:[animation-delay:80ms] [&:nth-child(4)>*]:[animation-delay:120ms] [&:nth-child(5)>*]:[animation-delay:160ms] [&:nth-child(n+6)>*]:[animation-delay:200ms]\">\n      <PostContext.Provider value={id}>\n        <article\n          aria-labelledby={`${id}-title`}\n          data-slot=\"community-feed-post\"\n          className={cn(\n            \"grid min-w-0 animate-in content-start fade-in-0 slide-in-from-bottom-1 duration-240 ease-out-quint fill-mode-both motion-reduce:animate-none\",\n            variant === \"list\" && \"gap-2.5 rounded-none border-b bg-transparent px-0 py-4\",\n            variant === \"cards\" && \"gap-2.5 rounded-[14px] bg-card p-4\",\n            variant === \"compact\" && \"gap-1.5 rounded-xl bg-card p-3\",\n            className,\n          )}\n          {...props}\n        />\n      </PostContext.Provider>\n    </li>\n  );\n}\n\n/** Author and posting time. */\nexport function CommunityFeedPostHeader({ className, ...props }: ComponentProps<\"header\">) {\n  usePost(\"CommunityFeedPostHeader\");\n  return (\n    <header\n      data-slot=\"community-feed-post-header\"\n      className={cn(\"flex min-w-0 flex-wrap items-center justify-between gap-2\", className)}\n      {...props}\n    />\n  );\n}\n\n/** The post author, an inline Author Card. Compose Author Card parts inside it. */\nexport function CommunityFeedPostAuthor({ className, ...props }: Omit<AuthorCardProps, \"variant\">) {\n  usePost(\"CommunityFeedPostAuthor\");\n  return (\n    <AuthorCard {...props} variant=\"inline\" className={cn(\"items-center gap-y-0\", className)} />\n  );\n}\n\nexport function CommunityFeedPostTime({\n  className,\n  ...props\n}: ComponentProps<\"time\"> & { dateTime: string }) {\n  usePost(\"CommunityFeedPostTime\");\n  return (\n    <time\n      data-slot=\"community-feed-post-time\"\n      className={cn(\"text-[12px] text-subtle-foreground tabular-nums\", className)}\n      {...props}\n    />\n  );\n}\n\n/** The post title. Pass `href` to link to the full post. */\nexport function CommunityFeedPostTitle({\n  href,\n  className,\n  children,\n  ...props\n}: ComponentProps<\"h3\"> & { href?: string }) {\n  const id = usePost(\"CommunityFeedPostTitle\");\n  const { variant } = useFeed(\"CommunityFeedPostTitle\");\n  return (\n    <h3\n      data-slot=\"community-feed-post-title\"\n      className={cn(\n        \"m-0 font-medium text-balance\",\n        variant === \"compact\" ? \"text-[13px]/[18px]\" : \"text-[15px]/[22px]\",\n        className,\n      )}\n      {...props}\n      id={`${id}-title`}\n    >\n      {href ? (\n        <a href={href} className={cn(\"text-inherit\", feedLink)}>\n          {children}\n        </a>\n      ) : (\n        children\n      )}\n    </h3>\n  );\n}\n\nexport function CommunityFeedPostBody({ className, ...props }: ComponentProps<\"p\">) {\n  const { variant } = useFeed(\"CommunityFeedPostBody\");\n  return (\n    <p\n      data-slot=\"community-feed-post-body\"\n      className={cn(\n        \"m-0 text-pretty wrap-anywhere text-muted-foreground\",\n        variant === \"compact\" && \"line-clamp-2\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\nexport function CommunityFeedPostTags({\n  \"aria-label\": label = \"Tags\",\n  className,\n  ...props\n}: ComponentProps<\"ul\">) {\n  usePost(\"CommunityFeedPostTags\");\n  return (\n    <ul\n      aria-label={label}\n      data-slot=\"community-feed-post-tags\"\n      className={cn(\"m-0 flex list-none flex-wrap gap-1 p-0\", className)}\n      {...props}\n    />\n  );\n}\n\nexport function CommunityFeedPostTag({ className, ...props }: ComponentProps<\"li\">) {\n  return (\n    <li\n      data-slot=\"community-feed-post-tag\"\n      className={cn(\n        \"inline-flex h-5 items-center rounded-md bg-muted px-2 text-[11.5px] font-medium text-muted-foreground\",\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** Reactions, reply counts, and post actions. */\nexport function CommunityFeedPostFooter({ className, ...props }: ComponentProps<\"footer\">) {\n  usePost(\"CommunityFeedPostFooter\");\n  return (\n    <footer\n      data-slot=\"community-feed-post-footer\"\n      className={cn(\"flex min-w-0 flex-wrap items-center gap-2\", className)}\n      {...props}\n    />\n  );\n}\n\n/** A Reaction Bar sized for the feed. */\nexport function CommunityFeedPostReactions(props: Omit<ReactionBarProps, \"variant\">) {\n  const { variant } = useFeed(\"CommunityFeedPostReactions\");\n  return <ReactionBar {...props} variant={reactionVariants[variant]} />;\n}\n\n/** A muted link or label such as \"12 replies\". */\nexport function CommunityFeedPostMeta({ className, ...props }: ComponentProps<\"a\">) {\n  usePost(\"CommunityFeedPostMeta\");\n  return (\n    <a\n      data-slot=\"community-feed-post-meta\"\n      className={cn(\n        \"ms-auto inline-flex items-center gap-1.5 text-[12px] text-subtle-foreground tabular-nums\",\n        feedLink,\n        className,\n      )}\n      {...props}\n    />\n  );\n}\n\n/** Shown when no posts match. Compose Empty State parts inside it. */\nexport function CommunityFeedEmpty(props: Omit<EmptyStateProps, \"variant\">) {\n  const { variant } = useFeed(\"CommunityFeedEmpty\");\n  return <EmptyState {...props} variant={variant === \"compact\" ? \"compact\" : \"card\"} />;\n}\n\nfunction pageItems(page: number, count: number): (number | \"gap\")[] {\n  if (count <= 7) return Array.from({ length: count }, (_, index) => index + 1);\n  const pages = new Set([1, count, page - 1, page, page + 1]);\n  const sorted = [...pages].filter((item) => item >= 1 && item <= count).sort((a, b) => a - b);\n  return sorted.flatMap((item, index) =>\n    index > 0 && item - (sorted[index - 1] ?? item) > 1 ? [\"gap\" as const, item] : [item],\n  );\n}\n\n/** Numbered pagination. The current page is marked with aria-current. */\nexport function CommunityFeedPagination({\n  pageCount,\n  \"aria-label\": label = \"Pagination\",\n  className,\n  ...props\n}: ComponentProps<\"nav\"> & { pageCount: number }) {\n  const context = useFeed(\"CommunityFeedPagination\");\n  if (pageCount <= 1) return null;\n  const button = (current: boolean) =>\n    cn(\n      \"inline-grid cursor-pointer place-items-center gap-0 rounded-full border-0 px-1.5 py-0 text-[12.5px] font-medium tabular-nums has-[>svg]:px-1.5 disabled:opacity-40\",\n      context.variant === \"compact\" ? \"h-6 min-w-6\" : \"h-7 min-w-7\",\n      current ? pillSelected : pillIdle,\n      \"hover:bg-accent hover:text-foreground dark:hover:bg-accent\",\n      pill,\n      pillFocus,\n    );\n  const { page, setPage } = context;\n  return (\n    <nav\n      aria-label={label}\n      data-slot=\"community-feed-pagination\"\n      className={cn(\"flex flex-wrap items-center justify-center gap-0.5\", className)}\n      {...props}\n    >\n      <Button\n        variant=\"ghost\"\n        type=\"button\"\n        aria-label=\"Previous page\"\n        disabled={page <= 1}\n        onClick={() => setPage(page - 1)}\n        className={button(false)}\n      >\n        <ChevronLeft size={14} className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n      </Button>\n      {pageItems(page, pageCount).map((item, index) =>\n        item === \"gap\" ? (\n          <span\n            // biome-ignore lint/suspicious/noArrayIndexKey: gaps have no identity beyond their position.\n            key={`gap-${index}`}\n            aria-hidden=\"true\"\n            className=\"px-1 text-subtle-foreground\"\n          >\n            …\n          </span>\n        ) : (\n          <Button\n            variant=\"ghost\"\n            key={item}\n            type=\"button\"\n            aria-label={`Page ${item}`}\n            aria-current={item === page ? \"page\" : undefined}\n            onClick={() => setPage(item)}\n            className={button(item === page)}\n          >\n            {item}\n          </Button>\n        ),\n      )}\n      <Button\n        variant=\"ghost\"\n        type=\"button\"\n        aria-label=\"Next page\"\n        disabled={page >= pageCount}\n        onClick={() => setPage(page + 1)}\n        className={button(false)}\n      >\n        <ChevronRight size={14} className=\"size-3.5\" strokeWidth={1.75} aria-hidden=\"true\" />\n      </Button>\n    </nav>\n  );\n}\n\nfunction moveRadio(event: KeyboardEvent<HTMLElement>) {\n  const radios = Array.from(\n    event.currentTarget.querySelectorAll<HTMLButtonElement>('[role=\"radio\"]:not(:disabled)'),\n  );\n  const index = radios.indexOf(document.activeElement as HTMLButtonElement);\n  if (index === -1) return;\n  const next = {\n    ArrowRight: index + 1,\n    ArrowDown: index + 1,\n    ArrowLeft: index - 1,\n    ArrowUp: index - 1,\n    Home: 0,\n    End: radios.length - 1,\n  }[event.key];\n  if (next === undefined) return;\n  event.preventDefault();\n  const target = radios[(next + radios.length) % radios.length];\n  target?.focus();\n  target?.click();\n}\n",
      "type": "registry:component",
      "target": "@components/uai/community-feed.tsx"
    }
  ],
  "categories": [
    "content",
    "community"
  ],
  "type": "registry:block"
}