{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "feed",
  "type": "registry:ui",
  "title": "Feed",
  "description": "A role=\"feed\" container of <article> items that loads more as the user scrolls (htmx infinite scroll via hx-trigger=\"revealed\"), following the WAI-ARIA APG Feed pattern. A structure, not a widget — screen readers stay in reading mode.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/feed.tsx",
      "type": "registry:ui",
      "target": "components/ui/feed.tsx",
      "content": "/** @jsxImportSource hono/jsx */\nimport type { PropsWithChildren } from \"hono/jsx\"\nimport { cn, type ClassValue } from \"@/registry/lib/cn\"\n\n// Feed — shadcn-htmx, htmx v4 + Tailwind v4.\n//\n// shadcn/ui has no \"feed\" component (it's a structural ARIA pattern, not a\n// widget), so there's no React source of truth to mirror. We build it\n// straight from the WAI-ARIA APG Feed pattern:\n//   repos/aria-practices/content/patterns/feed/feed-pattern.html\n//   repos/aria-practices/content/patterns/feed/examples/feed-display.html\n//   repos/aria-practices/content/patterns/feed/examples/js/feed.js\n//     (the PageUp/PageDown + Ctrl+Home/End reference implementation our\n//      site.js keyboard contract is modelled on)\n//\n// Why this shape:\n//   - A feed is a STRUCTURE, not a widget. Screen readers stay in reading\n//     mode; the role=\"feed\" container establishes an interoperability\n//     contract for reliably loading content as the user scrolls (APG\n//     \"About This Pattern\"). So the container is a plain <div role=\"feed\">,\n//     NOT focusable.\n//   - Each unit of content is a real <article> (which already maps to\n//     role=\"article\" per the HTML AAM — we set role=\"article\" explicitly to\n//     stay faithful to the APG example markup and defensive against older AT).\n//     repos/mdn/files/en-us/web/html/reference/elements/article/index.md\n//   - Each article is focusable (tabindex=\"0\") so AT reading cursors can land\n//     on it and the page can scroll it into view (APG: the article containing\n//     the reading cursor must contain DOM focus).\n//   - aria-posinset / aria-setsize position each article in the set; setsize\n//     can be -1 when the total is unknown (infinite feed). APG roles/states.\n//   - aria-labelledby names each article from its title; aria-describedby\n//     points at the primary content so AT users can skim.\n//   - aria-busy on the feed flips true while a batch is loading and false\n//     once the DOM is stable. APG: \"extremely important that aria-busy is set\n//     to false when the operation is complete.\" With htmx the busy attribute\n//     rides on the freshly-swapped placeholder, so it's only present during\n//     the in-flight request.\n//\n// htmx infinite scroll (verified against v4):\n//   repos/htmx/www/src/content/patterns/01-loading/02-infinite-scroll.md\n//   repos/htmx/www/src/content/reference/01-attributes/06-hx-trigger.md#revealed\n//   The trailing sentinel uses hx-trigger=\"revealed\" + hx-get + hx-swap=\n//   \"outerHTML\": when it scrolls into view it requests the next page, and the\n//   response (next articles + a fresh sentinel) replaces it — a self-extending\n//   chain. (Use \"intersect once\" instead when the feed lives inside an\n//   overflow-y:scroll container, per the htmx docs.)\n//\n// The component is layout-only: you hand it <FeedArticle> children and a\n// <FeedSentinel>. The keyboard contract lives in public/site.js keyed on\n// data-slot=\"feed\".\n\ntype FeedProps = PropsWithChildren<{\n  // The feed needs an accessible name. Prefer ariaLabelledby pointing at a\n  // visible heading; fall back to ariaLabel when there's no visible title.\n  ariaLabel?: string\n  ariaLabelledby?: string\n  // True while a batch of articles is being added/removed. With htmx this is\n  // usually set on the sentinel placeholder, not here.\n  busy?: boolean\n  class?: ClassValue\n  id?: string\n  // htmx / data / aria attributes ride onto the feed container.\n  [key: `hx-${string}`]: any\n  [key: `data-${string}`]: any\n  [key: `aria-${string}`]: any\n}>\n\nexport function Feed(props: FeedProps) {\n  const { ariaLabel, ariaLabelledby, busy, class: className, id, children, ...rest } =\n    props as any\n  return (\n    <div\n      id={id}\n      role=\"feed\"\n      data-slot=\"feed\"\n      aria-label={ariaLabelledby ? undefined : ariaLabel}\n      aria-labelledby={ariaLabelledby}\n      aria-busy={busy ? \"true\" : undefined}\n      class={cn(\"flex flex-col gap-4\", className)}\n      {...rest}\n    >\n      {children}\n    </div>\n  )\n}\n\ntype FeedArticleProps = PropsWithChildren<{\n  // 1-based position in the feed.\n  posinset: number\n  // Total articles loaded (or total in the feed). Pass -1 when unknown.\n  setsize: number\n  // Id of the element inside this article that names it (the title). APG\n  // requires each article to be labelled by its distinguishing content.\n  labelledby: string\n  // Id(s) of the element(s) providing the primary content, so AT can skim.\n  describedby?: string\n  // 0 (default) or -1. MDN's feed role allows each article to be focusable\n  // \"with tabindex of 0 or -1\"; pass -1 to keep a long feed to a single Tab\n  // stop via a roving tabindex (the active article alone stays in the Tab\n  // sequence; Page Up/Down moves between articles).\n  //   repos/mdn/files/en-us/web/accessibility/aria/reference/roles/feed_role/index.md\n  tabindex?: 0 | -1\n  class?: ClassValue\n  id?: string\n  [key: `hx-${string}`]: any\n  [key: `data-${string}`]: any\n  [key: `aria-${string}`]: any\n}>\n\nexport function FeedArticle(props: FeedArticleProps) {\n  const {\n    posinset,\n    setsize,\n    labelledby,\n    describedby,\n    tabindex = 0,\n    class: className,\n    id,\n    children,\n    ...rest\n  } = props as any\n  return (\n    <article\n      id={id}\n      role=\"article\"\n      data-slot=\"feed-article\"\n      // Focusable so the AT reading cursor can rest on it and the page can\n      // scroll it into view (APG tabindex=\"0\" on each article; MDN allows -1\n      // for a roving-tabindex feed).\n      tabindex={tabindex}\n      aria-posinset={posinset}\n      aria-setsize={setsize}\n      aria-labelledby={labelledby}\n      aria-describedby={describedby}\n      class={cn(\n        \"rounded-xl border bg-card p-5 text-card-foreground shadow-sm\",\n        \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none\",\n        className,\n      )}\n      {...rest}\n    >\n      {children}\n    </article>\n  )\n}\n\n// Trailing placeholder that loads the next page when it scrolls into view.\n// Defaults to the htmx infinite-scroll contract from the v4 docs: revealed +\n// outerHTML so the response (next articles + a new sentinel) replaces it.\n// Omit the sentinel from the server response when there are no more pages and\n// the chain stops naturally.\ntype FeedSentinelProps = PropsWithChildren<{\n  // The next-page URL. Sets hx-get for you.\n  href?: string\n  // Default \"revealed\"; pass \"intersect once\" when the feed scrolls inside an\n  // overflow container (htmx docs note).\n  trigger?: string\n  // Marks the in-flight placeholder busy with aria-busy=\"true\" while a batch\n  // loads — the documented \"aria-busy rides on the sentinel\" contract. The\n  // outerHTML swap clears it by replacing this element with the response, so\n  // busy never has to be reset to false manually (APG: aria-busy must be\n  // false once the operation completes).\n  //   repos/aria-practices/content/patterns/feed/feed-pattern.html\n  busy?: boolean\n  class?: ClassValue\n  id?: string\n  [key: `hx-${string}`]: any\n  [key: `data-${string}`]: any\n}>\n\nexport function FeedSentinel(props: FeedSentinelProps) {\n  const { href, trigger = \"revealed\", busy, class: className, id, children, ...rest } =\n    props as any\n  return (\n    <div\n      id={id}\n      data-slot=\"feed-sentinel\"\n      hx-get={href}\n      hx-trigger={trigger}\n      hx-swap=\"outerHTML\"\n      aria-busy={busy ? \"true\" : undefined}\n      class={cn(\n        \"flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground\",\n        className,\n      )}\n      {...rest}\n    >\n      {children ?? (\n        <>\n          <span\n            aria-hidden=\"true\"\n            class=\"size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground\"\n          />\n          Loading more…\n        </>\n      )}\n    </div>\n  )\n}\n"
    },
    {
      "path": "registry/jinja2/feed.html",
      "type": "registry:file",
      "target": "templates/components/feed.html",
      "content": "{# Feed macros — shadcn-htmx, htmx v4 + Tailwind v4.\n   Mirrors registry/ui/feed.tsx.\n\n   A role=\"feed\" container of role=\"article\" items, per the WAI-ARIA APG\n   Feed pattern. The trailing sentinel uses hx-trigger=\"revealed\" to load\n   the next page (htmx infinite scroll).\n\n   Usage:\n     {% from \"components/feed.html\" import feed_open, feed_close, feed_article,\n        feed_sentinel %}\n\n     {{ feed_open(aria_labelledby=\"feed-title\") }}\n       {% call feed_article(posinset=1, setsize=-1, labelledby=\"post-1-title\",\n          describedby=\"post-1-body\", id=\"post-1\") %}\n         <h3 id=\"post-1-title\" class=\"font-semibold\">Title</h3>\n         <p id=\"post-1-body\" class=\"mt-1 text-sm text-muted-foreground\">Body…</p>\n       {% endcall %}\n       {{ feed_sentinel(href=\"/feed/page?page=2\") }}\n     {{ feed_close() }} #}\n\n{% macro feed_open(aria_label=none, aria_labelledby=none, busy=false, id=none, extra_class=\"\") %}\n<div\n  {%- if id %} id=\"{{ id }}\"{% endif %}\n  role=\"feed\" data-slot=\"feed\"\n  {% if aria_labelledby %}aria-labelledby=\"{{ aria_labelledby }}\"{% elif aria_label %}aria-label=\"{{ aria_label }}\"{% endif %}\n  {% if busy %}aria-busy=\"true\"{% endif %}\n  class=\"flex flex-col gap-4 {{ extra_class }}\">\n{% endmacro %}\n\n{% macro feed_close() %}\n</div>\n{% endmacro %}\n\n{# tabindex: 0 (default) or -1 — MDN's feed role allows each article to be\n   focusable \"with tabindex of 0 or -1\"; pass -1 for a roving-tabindex feed. #}\n{% macro feed_article(posinset, setsize, labelledby, describedby=none, tabindex=0, id=none, extra_class=\"\", **attrs) %}\n<article\n  {%- if id %} id=\"{{ id }}\"{% endif %}\n  role=\"article\" data-slot=\"feed-article\" tabindex=\"{{ tabindex }}\"\n  aria-posinset=\"{{ posinset }}\" aria-setsize=\"{{ setsize }}\"\n  aria-labelledby=\"{{ labelledby }}\"\n  {% if describedby %}aria-describedby=\"{{ describedby }}\"{% endif %}\n  class=\"rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none {{ extra_class }}\"\n  {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}=\"{{ v }}\"{% endfor -%}\n>{{ caller() }}</article>\n{% endmacro %}\n\n{# busy: aria-busy=\"true\" on the in-flight placeholder while a batch loads;\n   the outerHTML swap clears it by replacing this element (APG: aria-busy must\n   be false once the operation completes). #}\n{% macro feed_sentinel(href=none, trigger=\"revealed\", busy=false, id=none, extra_class=\"\") %}\n<div\n  {%- if id %} id=\"{{ id }}\"{% endif %}\n  data-slot=\"feed-sentinel\"\n  {% if href %}hx-get=\"{{ href }}\"{% endif %}\n  hx-trigger=\"{{ trigger }}\" hx-swap=\"outerHTML\"\n  {% if busy %}aria-busy=\"true\"{% endif %}\n  class=\"flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground {{ extra_class }}\">\n  {% if caller %}{{ caller() }}{% else %}<span aria-hidden=\"true\" class=\"size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground\"></span>Loading more…{% endif %}\n</div>\n{% endmacro %}\n"
    },
    {
      "path": "registry/go-templates/feed.tmpl",
      "type": "registry:file",
      "target": "components/feed.tmpl",
      "content": "{{/* Feed templates — shadcn-htmx, htmx v4 + Tailwind v4.\n     Mirrors registry/ui/feed.tsx.\n\n     role=\"feed\" container of role=\"article\" items (WAI-ARIA APG Feed pattern).\n     The trailing sentinel uses hx-trigger=\"revealed\" to load the next page.\n\n     Usage:\n       {{template \"feed\" (dict \"AriaLabelledby\" \"feed-title\" \"Body\" (htmlSafe `\n         {{template \"feed_article\" (dict \"Posinset\" 1 \"Setsize\" -1\n            \"Labelledby\" \"post-1-title\" \"Describedby\" \"post-1-body\" \"ID\" \"post-1\"\n            \"Body\" (htmlSafe `<h3 id=\"post-1-title\" class=\"font-semibold\">Title</h3>\n            <p id=\"post-1-body\" class=\"mt-1 text-sm text-muted-foreground\">Body…</p>`))}}\n         {{template \"feed_sentinel\" (dict \"Href\" \"/feed/page?page=2\")}}`))}} */}}\n\n{{define \"feed\"}}\n<div {{if .ID}}id=\"{{.ID}}\"{{end}}\n     role=\"feed\" data-slot=\"feed\"\n     {{if .AriaLabelledby}}aria-labelledby=\"{{.AriaLabelledby}}\"{{else if .AriaLabel}}aria-label=\"{{.AriaLabel}}\"{{end}}\n     {{if .Busy}}aria-busy=\"true\"{{end}}\n     class=\"flex flex-col gap-4 {{.Class}}\">{{.Body}}</div>\n{{end}}\n\n{{/* Tabindex: 0 (default) or -1 — MDN's feed role allows each article to be\n     focusable \"with tabindex of 0 or -1\"; pass -1 for a roving-tabindex feed. */}}\n{{define \"feed_article\"}}\n{{- $setsize := or .Setsize -1 -}}\n{{- $tabindex := or .Tabindex 0 -}}\n<article {{if .ID}}id=\"{{.ID}}\"{{end}}\n         role=\"article\" data-slot=\"feed-article\" tabindex=\"{{$tabindex}}\"\n         aria-posinset=\"{{.Posinset}}\" aria-setsize=\"{{$setsize}}\"\n         aria-labelledby=\"{{.Labelledby}}\"\n         {{if .Describedby}}aria-describedby=\"{{.Describedby}}\"{{end}}\n         class=\"rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none {{.Class}}\">{{.Body}}</article>\n{{end}}\n\n{{/* Busy: aria-busy=\"true\" on the in-flight placeholder while a batch loads;\n     the outerHTML swap clears it by replacing this element (APG: aria-busy must\n     be false once the operation completes). */}}\n{{define \"feed_sentinel\"}}\n{{- $trigger := or .Trigger \"revealed\" -}}\n<div {{if .ID}}id=\"{{.ID}}\"{{end}}\n     data-slot=\"feed-sentinel\"\n     {{if .Href}}hx-get=\"{{.Href}}\"{{end}}\n     hx-trigger=\"{{$trigger}}\" hx-swap=\"outerHTML\"\n     {{if .Busy}}aria-busy=\"true\"{{end}}\n     class=\"flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground {{.Class}}\">{{if .Body}}{{.Body}}{{else}}<span aria-hidden=\"true\" class=\"size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground\"></span>Loading more…{{end}}</div>\n{{end}}\n"
    },
    {
      "path": "registry/phoenix/feed.ex",
      "type": "registry:file",
      "target": "lib/my_app_web/components/feed.ex",
      "content": "defmodule ShadcnHtmx.Components.Feed do\n  @moduledoc \"\"\"\n  Feed — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.\n\n  A `role=\"feed\"` container of `role=\"article\"` items, following the\n  WAI-ARIA APG Feed pattern. A feed is a STRUCTURE, not a widget: screen\n  readers stay in reading mode while the page loads content as the user\n  scrolls. Each article is focusable (`tabindex=\"0\"`) and carries\n  `aria-posinset` / `aria-setsize` (use `-1` for an unknown total),\n  `aria-labelledby` (its title) and `aria-describedby` (its primary\n  content, so AT users can skim).\n\n  The trailing sentinel uses htmx `hx-trigger=\"revealed\"` + `hx-swap=\"outerHTML\"`\n  to load the next page (infinite scroll) — the response (next articles plus a\n  fresh sentinel) replaces it, forming a self-extending chain.\n\n  ## Examples\n\n      <.feed aria-labelledby=\"feed-title\">\n        <.feed_article posinset={1} setsize={-1} labelledby=\"post-1-title\"\n                       describedby=\"post-1-body\" id=\"post-1\">\n          <h3 id=\"post-1-title\" class=\"font-semibold\">Title</h3>\n          <p id=\"post-1-body\" class=\"mt-1 text-sm text-muted-foreground\">Body…</p>\n        </.feed_article>\n        <.feed_sentinel href={~p\"/feed/page?page=2\"} />\n      </.feed>\n  \"\"\"\n\n  use Phoenix.Component\n\n  attr :\"aria-label\", :string, default: nil\n  attr :\"aria-labelledby\", :string, default: nil\n  attr :busy, :boolean, default: false\n  attr :class, :string, default: nil\n  attr :rest, :global\n  slot :inner_block, required: true\n\n  def feed(assigns) do\n    ~H\"\"\"\n    <div\n      role=\"feed\"\n      data-slot=\"feed\"\n      aria-label={!assigns[:\"aria-labelledby\"] && assigns[:\"aria-label\"]}\n      aria-labelledby={assigns[:\"aria-labelledby\"]}\n      aria-busy={@busy && \"true\"}\n      class={[\"flex flex-col gap-4\", @class]}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </div>\n    \"\"\"\n  end\n\n  attr :posinset, :integer, required: true\n  attr :setsize, :integer, required: true\n  attr :labelledby, :string, required: true\n  attr :describedby, :string, default: nil\n  # 0 (default) or -1 — MDN's feed role allows each article to be focusable\n  # \"with tabindex of 0 or -1\"; pass -1 for a roving-tabindex feed.\n  attr :tabindex, :integer, default: 0\n  attr :class, :string, default: nil\n  attr :rest, :global\n  slot :inner_block, required: true\n\n  def feed_article(assigns) do\n    ~H\"\"\"\n    <article\n      role=\"article\"\n      data-slot=\"feed-article\"\n      tabindex={@tabindex}\n      aria-posinset={@posinset}\n      aria-setsize={@setsize}\n      aria-labelledby={@labelledby}\n      aria-describedby={@describedby}\n      class={[\n        \"rounded-xl border bg-card p-5 text-card-foreground shadow-sm\",\n        \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none\",\n        @class\n      ]}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </article>\n    \"\"\"\n  end\n\n  attr :href, :string, default: nil\n  attr :trigger, :string, default: \"revealed\"\n  # aria-busy=\"true\" on the in-flight placeholder while a batch loads; the\n  # outerHTML swap clears it by replacing this element (APG: aria-busy must be\n  # false once the operation completes).\n  attr :busy, :boolean, default: false\n  attr :class, :string, default: nil\n  attr :rest, :global\n  slot :inner_block\n\n  def feed_sentinel(assigns) do\n    ~H\"\"\"\n    <div\n      data-slot=\"feed-sentinel\"\n      hx-get={@href}\n      hx-trigger={@trigger}\n      hx-swap=\"outerHTML\"\n      aria-busy={@busy && \"true\"}\n      class={[\"flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground\", @class]}\n      {@rest}\n    >\n      <%= if @inner_block != [] do %>\n        {render_slot(@inner_block)}\n      <% else %>\n        <span\n          aria-hidden=\"true\"\n          class=\"size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground\"\n        />\n        Loading more…\n      <% end %>\n    </div>\n    \"\"\"\n  end\nend\n"
    },
    {
      "path": "registry/html/feed.html",
      "type": "registry:file",
      "target": "snippets/feed.html",
      "content": "<!--\n  shadcn-htmx — raw HTML feed snippet.\n  role=\"feed\" container of role=\"article\" items (WAI-ARIA APG Feed pattern).\n  Each article is focusable and carries aria-posinset / aria-setsize\n  (-1 = unknown total), aria-labelledby (title) and aria-describedby (body).\n  The trailing sentinel uses htmx hx-trigger=\"revealed\" to load the next\n  page; the response replaces it (hx-swap=\"outerHTML\"), extending the chain.\n  Relies only on the theme tokens in styles.css. The PageUp/PageDown +\n  Ctrl+Home/End keyboard contract is wired by site.js (data-slot=\"feed\").\n-->\n\n<h2 id=\"feed-title\" class=\"text-lg font-semibold tracking-tight\">Latest posts</h2>\n\n<div role=\"feed\" data-slot=\"feed\" aria-labelledby=\"feed-title\"\n     class=\"flex flex-col gap-4\">\n\n  <article role=\"article\" data-slot=\"feed-article\" tabindex=\"0\"\n           aria-posinset=\"1\" aria-setsize=\"-1\"\n           aria-labelledby=\"post-1-title\" aria-describedby=\"post-1-body\"\n           id=\"post-1\"\n           class=\"rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none\">\n    <h3 id=\"post-1-title\" class=\"font-semibold\">Shipping hypermedia at scale</h3>\n    <p id=\"post-1-body\" class=\"mt-1 text-sm text-muted-foreground\">\n      How we moved a dashboard from a SPA to server-rendered htmx — and why\n      the page got faster.\n    </p>\n  </article>\n\n  <article role=\"article\" data-slot=\"feed-article\" tabindex=\"0\"\n           aria-posinset=\"2\" aria-setsize=\"-1\"\n           aria-labelledby=\"post-2-title\" aria-describedby=\"post-2-body\"\n           id=\"post-2\"\n           class=\"rounded-xl border bg-card p-5 text-card-foreground shadow-sm focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-none\">\n    <h3 id=\"post-2-title\" class=\"font-semibold\">Tailwind v4: the Oxide engine</h3>\n    <p id=\"post-2-body\" class=\"mt-1 text-sm text-muted-foreground\">\n      A tour of CSS-first config, container queries, and the new color system.\n    </p>\n  </article>\n\n  <!-- Sentinel: when it scrolls into view, htmx GETs the next page and\n       replaces this node with (next articles + a fresh sentinel). Omit the\n       sentinel from the server response when there are no more pages. -->\n  <div data-slot=\"feed-sentinel\" hx-get=\"/feed/page?page=2\"\n       hx-trigger=\"revealed\" hx-swap=\"outerHTML\"\n       class=\"flex items-center justify-center gap-2 py-4 text-sm text-muted-foreground\">\n    <span aria-hidden=\"true\"\n          class=\"size-4 animate-spin rounded-full border-2 border-muted-foreground/30 border-t-muted-foreground\"></span>\n    Loading more…\n  </div>\n</div>\n"
    }
  ]
}
