{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "related-posts",
  "type": "registry:ui",
  "title": "Related Posts",
  "description": "\"Keep reading\" surface that sits at the foot of an article page (or any long-form route). Renders an h3 section heading followed by a responsive grid of ArticleCards: one column on phones, two from `md` up, three from `lg` up — the same 1/2/3 cadence the…",
  "author": "ofri-peretz <https://github.com/ofri-peretz>",
  "categories": [
    "blog",
    "pattern"
  ],
  "dependencies": [],
  "registryDependencies": [
    "https://ds.interlace.tools/r/theme.json",
    "https://ds.interlace.tools/r/article-card.json",
    "https://ds.interlace.tools/r/cn.json",
    "https://ds.interlace.tools/r/grid.json",
    "https://ds.interlace.tools/r/skeleton.json",
    "https://ds.interlace.tools/r/typography.json"
  ],
  "files": [
    {
      "path": "registry/interlace-ui/patterns/related-posts.tsx",
      "target": "components/ui/patterns/related-posts.tsx",
      "type": "registry:ui",
      "content": "import * as React from 'react';\n\n// @interlace/related-posts v1.3.0 — Interlace design system.\n// Docs, props and live preview: https://ds.interlace.tools/c/related-posts\n// What changed since: https://ds.interlace.tools/c/related-posts#history\n// Generated banner — keep it, the upgrade diff reads this version.\n\n/**\n * @interlace/ui — RelatedPosts\n *\n * \"Keep reading\" surface that sits at the foot of an article page (or any\n * long-form route). Renders an h3 section heading followed by a responsive\n * grid of ArticleCards: one column on phones, two from `md` up, three from\n * `lg` up — the same 1/2/3 cadence the homepage feed uses, so a reader who\n * scrolls between an article tail and the index sees the same rhythm.\n *\n * The block is intentionally a pure composition over `ArticleCard` (the\n * stacked grid tile): every metric this block could surface (date, kicker, summary)\n * already lives on ArticleCard's props, so this file owns ONLY the heading,\n * the grid, and the field mapping from the editorial-friendly post shape\n * (`href` / `title` / `summary` / `publishedDateIso` / `kicker?`) to the\n * card's prop names (`description` / `publishedAt` / `sourceLabel`). That\n * mapping is the entire reason this block exists — without it, every page\n * that wanted a \"related\" grid would re-roll its own naming.\n *\n * ## Anatomy\n *\n *   RelatedPosts                       (section — data-min-viewport=480)\n *     ├─ Typography h3                 (title — \"Related posts\" by default)\n *     └─ Grid cols=1 md:2 lg:3 gap=md  (one ArticleCard per post)\n *           └─ ArticleCard\n *\n * ## MIN_VIEWPORT — 480\n *\n * The block degrades gracefully to one column below 480 (the heading still\n * reads, the cards still tap), but the editorial intent is \"a row of cards\"\n * — at 320 you get a single-file list that's indistinguishable from a plain\n * link list, which defeats the purpose of using card chrome. Pages that\n * must work below 480 should reach for a `<ul>` of plain anchors instead.\n *\n * | Rule | Concept                          | Where in this file                                          |\n * | ---- | -------------------------------- | ----------------------------------------------------------- |\n * | R4   | Extends native el                | `React.ComponentProps<'section'> & RelatedPostsProps`       |\n * | R6   | data-slot on root                | `data-slot=\"related-posts\"`                                 |\n * | R7   | className merged + ...rest       | `cn(BASE, className)` + `{...props}`                        |\n * | R8   | No `isXxx`; no boolean variants  | n/a — composition-only block, no variants                   |\n * | R10  | Composition seam                 | `title` slot + `posts` array mapped into ArticleCard        |\n * | R14  | Declares min viewport            | `data-min-viewport={String(MIN_VIEWPORT)}` + exported const |\n * | R18  | Tailwind only                    | Zero inline `style`; utility classes + primitives           |\n * | R19  | Tokens only                      | Spacing via `--spacing-*` (gap-md / mb-md), no raw px       |\n * | R20  | AA contrast                      | Heading + card surfaces use semantic tokens (AA-cleared)    |\n * | R25  | Server component                 | No hooks → no `'use client'`                                |\n * | R26  | A11y from native el              | `<section>` landmark; heading carries the accessible name   |\n */\n\nimport { cn } from '@/lib/utils';\nimport { Grid } from '@/components/ui/grid';\nimport { Skeleton } from '@/components/ui/skeleton';\nimport { Typography } from '@/components/ui/typography';\nimport { ArticleCard } from '@/components/ui/patterns/article-card';\n\nexport const MIN_VIEWPORT = 480 as const;\n\n/**\n * Editorial-friendly post shape. Maps onto `ArticleCard` as:\n *\n *   { href, title }            → ArticleCard {href, title}        (identity)\n *   summary                    → ArticleCard.description\n *   publishedDateIso           → ArticleCard.publishedAt           (ISO string)\n *   kicker?                    → ArticleCard.sourceLabel           (small uppercase chip)\n *\n * Why a separate shape? CMS / MDX frontmatter and blog data sources speak\n * `summary` + `publishedDateIso`, not `description` + `publishedAt`. Owning\n * the rename here keeps every consumer's data layer clean.\n */\nexport interface RelatedPost {\n  /** Destination URL — same semantics as `ArticleCard.href`. */\n  href: string;\n  /** Headline — same semantics as `ArticleCard.title`. */\n  title: string;\n  /** Short excerpt under the headline. Renders as the card description. */\n  summary: string;\n  /**\n   * Publication date as an ISO-8601 string (e.g. `2026-05-10`). Mapped to\n   * `ArticleCard.publishedAt`; the card formats it as `Mar 5, 2026`.\n   */\n  publishedDateIso: string;\n  /**\n   * Optional small uppercase label (e.g. `\"Tutorial\"`, `\"Dev.to\"`). Maps to\n   * `ArticleCard.sourceLabel`, which renders it as the top-right chip on\n   * the cover.\n   */\n  kicker?: string;\n}\n\n// `Omit<…, 'title'>` matches every sibling pattern (article-list-grid, faq,\n// feature-grid, pricing-table, cta-section, testimonial-grid). Without it, our\n// ReactNode `title` intersects the native `<section title>` string attribute\n// and collapses to `string & ReactNode` — so the component's own\n// `typeof title === 'string'` branch is unreachable by the type checker and\n// consumers passing an element have to cast.\ntype RelatedPostsProps = Omit<React.ComponentProps<'section'>, 'title'> & {\n  /** Section heading. Default: `\"Related posts\"`. */\n  title?: React.ReactNode;\n  /** Posts to render. One ArticleCard per entry, in order. Optional when `loading={true}`. */\n  posts?: RelatedPost[];\n  /**\n   * When true, render a grid of `<Skeleton variant=\"article-card\" />`\n   * placeholders (count derived from `loadingCount`, default 3) so the\n   * page reserves the eventual grid footprint while data loads.\n   */\n  loading?: boolean;\n  /** How many skeleton cards to render when `loading={true}`. Default 3. */\n  loadingCount?: number;\n  /**\n   * Stable selector hook for E2E tests. Each card derives its own id\n   * (`{value}-card-0`, `{value}-card-1`, …). Required — no default (R5).\n   */\n  'data-testid': string;\n};\n\n/**\n * \"Keep reading\" grid. Server component (no hooks).\n *\n * Renders nothing (returns `null`) when `posts` is empty, so callers can\n * pass a possibly-empty array without guarding at the call site — matches\n * `EmptyState`'s contract that \"empty is a first-class state owned by the\n * consumer, not the block\".\n */\nexport function RelatedPosts({\n  className,\n  title = 'Related posts',\n  posts,\n  loading,\n  loadingCount = 3,\n  'data-testid': testId,\n  ...props\n}: RelatedPostsProps) {\n  if (loading) {\n    return (\n      <section\n        data-slot=\"related-posts\"\n        data-testid={testId}\n        data-min-viewport={String(MIN_VIEWPORT)}\n        aria-label={typeof title === 'string' ? title : undefined}\n        aria-busy=\"true\"\n        className={cn('w-full', className)}\n        {...props}\n      >\n        <Typography variant=\"h3\" as=\"h2\" className=\"mb-md\">\n          {title}\n        </Typography>\n        <Grid cols={3} gap=\"md\">\n          {Array.from({ length: loadingCount }).map((_, i) => (\n            <Skeleton key={i} variant=\"article-card\" label={null} />\n          ))}\n        </Grid>\n      </section>\n    );\n  }\n  if (!posts || posts.length === 0) return null;\n\n  return (\n    <section\n      data-slot=\"related-posts\"\n      data-testid={testId}\n      data-min-viewport={String(MIN_VIEWPORT)}\n      aria-label={typeof title === 'string' ? title : undefined}\n      className={cn('w-full', className)}\n      {...props}\n    >\n      <Typography as=\"h2\" variant=\"h3\" className=\"mb-md\">\n        {title}\n      </Typography>\n      {/*\n        Grid's `cols` variant is static (R21 closed set). To get the\n        responsive 1 / md:2 / lg:3 cadence the spec calls for, we ground the\n        base track at cols=1 and override at md/lg via Tailwind responsive\n        classes — these win at the breakpoint regardless of source order.\n      */}\n      <Grid\n        cols={1}\n        gap=\"md\"\n        className=\"md:grid-cols-2 lg:grid-cols-3\"\n      >\n        {posts.map((post, i) => (\n          <ArticleCard\n            key={post.href}\n            href={post.href}\n            title={post.title}\n            description={post.summary}\n            publishedAt={post.publishedDateIso}\n            sourceLabel={post.kicker}\n            data-testid={`${testId}-card-${i}`}\n          />\n        ))}\n      </Grid>\n    </section>\n  );\n}\n"
    }
  ],
  "meta": {
    "tier": "pattern",
    "client": false,
    "minViewport": 480,
    "loading": true,
    "version": "1.3.0",
    "since": "1.0.0"
  },
  "docs": "## @interlace/related-posts\n\nInstalled to `components/ui/patterns/related-posts.tsx`.\n\n```tsx\nimport { /* … */ } from '@/components/ui/patterns/related-posts';\n```\n\nProps, a11y contract, live preview and source: https://ds.interlace.tools/c/related-posts\n\nRequires the `@interlace/theme` CSS baseline (installed automatically as a registry dependency)."
}
