{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "optimistic-toggle",
  "type": "registry:ui",
  "title": "Optimistic Toggle",
  "description": "A server-backed action toggle (like, star, follow, pin) that flips appearance instantly via a native <template> of the toggled state, then reconciles with the server's HTML response — rolling back automatically on error. Built on a real <button> with aria-pressed and the htmx v4 hx-optimistic extension. Ships in five flavours: Hono JSX (TypeScript), Jinja2 macro, Go html/template, Phoenix function component, and a raw HTML snippet. Follows the WAI-ARIA APG button (toggle) pattern.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/optimistic-toggle.tsx",
      "type": "registry:ui",
      "target": "components/ui/optimistic-toggle.tsx",
      "content": "/** @jsxImportSource hono/jsx */\nimport type { Child } from \"hono/jsx\"\nimport { cn, type ClassValue } from \"@/registry/lib/cn\"\n\n// Optimistic Toggle — shadcn-htmx, htmx v4 + Tailwind v4.\n//\n// A server-backed action toggle (like / star / follow / pin). Clicking flips\n// the appearance INSTANTLY to the toggled state, then reconciles with the\n// server's HTML response — rolling back automatically if the request fails.\n//\n// Built on:\n//   - Real htmx v4 events, NOT an extension. htmx v4 does not ship a working\n//     \"optimistic\" attribute (the bundled src/ext/hx-optimistic.js is an\n//     unfinished stub — \"TODO: this needs to be updated to use the new internal\n//     API\" — and it does not flip aria-pressed nor cancel the error swap). So\n//     the behaviour is a tiny self-contained script (OPTIMISTIC_TOGGLE_JS,\n//     emitted once below) keyed on data-slot=\"optimistic-toggle\":\n//       • htmx:before:request — save the button's innerHTML + aria-pressed,\n//         then flip aria-pressed and paint the <template>'s optimistic markup\n//         in (the instant pre-network flip).\n//       • htmx:before:swap — if the response is 4xx/5xx (ctx.response.status),\n//         preventDefault() to CANCEL the swap (htmx v4 swaps error bodies by\n//         default — see repos/htmx/src/htmx.js:1224) and restore the saved\n//         markup + aria-pressed (rollback).\n//       • on success htmx's hx-swap=\"outerHTML\" replaces the button with the\n//         server's authoritative version — no rollback code needed.\n//     htmx v4 fires htmx:response:error for 4xx/5xx and lets a\n//     htmx:before:swap listener veto the swap via preventDefault:\n//     repos/htmx/CHANGELOG.md (htmx:response:error) and htmx.js:1224.\n//   - A native <template> holds the optimistic markup. <template> content is\n//     inert/not rendered until cloned, so it never shows until the script pulls\n//     its innerHTML. repos/mdn/files/en-us/web/html/reference/elements/template/index.md\n//   - A real <button> with aria-pressed: the platform gives us role=button and\n//     Space/Enter activation for free, and aria-pressed carries the toggle\n//     state. APG: Button (toggle) pattern — the accessible NAME must stay\n//     constant across states; only aria-pressed flips.\n//     repos/aria-practices/content/patterns/button/examples/button.html\n//     repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-pressed/index.md\n//\n// Style analogue: registry/ui/button.tsx (variant/size maps, .htmx-request\n// affordance, real <button>). We reuse Button's visual language.\n//\n// The target of the swap is the button itself (hx-target=\"this\",\n// hx-swap=\"outerHTML\"), so on success the server returns a fresh <button> in\n// the new state. The <template> lives OUTSIDE the swapped button (a sibling\n// inside the data-slot wrapper) so it survives the swap and stays available\n// for the next toggle. The optimistic source is pointed at by data-optimistic\n// (a plain CSS selector the script reads — no extension involved).\n\n// Shared optimistic-flip + rollback behaviour. Delegated on document.body so it\n// covers buttons swapped in by htmx too; attached once via a global guard. It\n// uses only real htmx v4 events and the platform <template> + aria-pressed, so\n// it needs no extension. Copy this once into your app (e.g. site.js) — the\n// component renders it inline for the docs/demo.\nexport const OPTIMISTIC_TOGGLE_JS = `(function(){\n  if (window.__shadcnOptimisticToggle) return;\n  window.__shadcnOptimisticToggle = true;\n\n  // Resolve the <button data-slot> for an event whose source is the toggle.\n  function toggleFor(detail){\n    var ctx = detail && detail.ctx;\n    var src = ctx && ctx.sourceElement;\n    if (!src || !src.closest) return null;\n    var btn = src.closest('[data-slot=\"optimistic-toggle\"] > button[aria-pressed]');\n    return btn || (src.matches && src.matches('button[aria-pressed]') &&\n      src.closest('[data-slot=\"optimistic-toggle\"]') ? src : null);\n  }\n\n  // Instant flip: stash the current markup, then paint the <template> in and\n  // toggle aria-pressed BEFORE the network round-trip.\n  document.body.addEventListener('htmx:before:request', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn) return;\n    btn.__optHTML = btn.innerHTML;\n    btn.__optPressed = btn.getAttribute('aria-pressed');\n    var sel = btn.getAttribute('data-optimistic');\n    var tmpl = sel && document.querySelector(sel);\n    var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot=\"optimistic-toggle-state\"]') : null;\n    if (inner) btn.innerHTML = inner.innerHTML;\n    btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');\n  }, true);\n\n  // On a 4xx/5xx response, cancel the swap (htmx v4 swaps error bodies by\n  // default) and roll the optimistic flip back to exactly what it was.\n  document.body.addEventListener('htmx:before:swap', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn || btn.__optHTML == null) return;\n    var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;\n    if (status >= 400){\n      e.preventDefault();\n      btn.innerHTML = btn.__optHTML;\n      btn.setAttribute('aria-pressed', btn.__optPressed);\n    }\n    btn.__optHTML = null;\n  }, true);\n\n  // Network/abort failure (no response): also roll back.\n  document.body.addEventListener('htmx:error', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn || btn.__optHTML == null) return;\n    btn.innerHTML = btn.__optHTML;\n    btn.setAttribute('aria-pressed', btn.__optPressed);\n    btn.__optHTML = null;\n  }, true);\n})();`\n\nexport type OptimisticToggleVariant = \"default\" | \"outline\" | \"ghost\"\nexport type OptimisticToggleSize = \"default\" | \"sm\" | \"lg\" | \"icon\"\n\nconst base =\n  \"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none \" +\n  \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 \" +\n  \"disabled:pointer-events-none disabled:opacity-50 \" +\n  \"aria-disabled:pointer-events-none aria-disabled:opacity-50 \" +\n  \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 \" +\n  // While the toggle request is in flight htmx adds .htmx-request to the\n  // trigger; dim slightly so the optimistic state still reads as \"pending\".\n  \"[&.htmx-request]:opacity-80\"\n\nconst variants: Record<OptimisticToggleVariant, string> = {\n  // The pressed look comes from aria-pressed (data-driven below), so each\n  // variant defines both the resting and the pressed treatment.\n  default:\n    \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground \" +\n    \"aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90\",\n  outline:\n    \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground \" +\n    \"aria-pressed:border-primary aria-pressed:text-primary aria-pressed:bg-primary/10 aria-pressed:hover:bg-primary/15\",\n  ghost:\n    \"hover:bg-accent hover:text-accent-foreground \" +\n    \"aria-pressed:bg-secondary aria-pressed:text-secondary-foreground aria-pressed:hover:bg-secondary/80\",\n}\n\nconst sizes: Record<OptimisticToggleSize, string> = {\n  default: \"h-9 px-4 py-2 has-[>svg]:px-3\",\n  sm: \"h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5\",\n  lg: \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n  icon: \"size-9\",\n}\n\nexport function optimisticToggleClasses(opts?: {\n  variant?: OptimisticToggleVariant\n  size?: OptimisticToggleSize\n  class?: ClassValue\n}): string {\n  const variant = opts?.variant ?? \"default\"\n  const size = opts?.size ?? \"default\"\n  return cn(base, variants[variant], sizes[size], opts?.class)\n}\n\ntype OptimisticToggleProps = {\n  // Unique id. Seeds the button id (`{id}`) and the optimistic template id\n  // (`{id}-optimistic`) that data-optimistic points the behaviour script at.\n  id: string\n  // Current persisted state (from your server / DB).\n  pressed?: boolean\n  variant?: OptimisticToggleVariant\n  size?: OptimisticToggleSize\n  class?: ClassValue\n  disabled?: boolean\n  // Stable accessible name. APG requires it not change between states —\n  // \"Like\" stays \"Like\" whether pressed or not; aria-pressed carries state.\n  ariaLabel?: string\n  ariaLabelledby?: string\n  ariaDescribedby?: string\n\n  // Visible content for the CURRENT (resting) state — icon and/or label.\n  children: Child\n  // Visible content for the OPTIMISTIC (just-toggled) state. Swapped in\n  // instantly on click via the <template>, before the server responds.\n  optimistic: Child\n\n  // htmx — where to POST the toggle. The server should reply with a fresh\n  // <button> in the new state (use OptimisticToggle again server-side).\n  \"hx-post\"?: string\n  \"hx-put\"?: string\n  \"hx-patch\"?: string\n  \"hx-delete\"?: string\n  // Defaults below target the button itself and swap its outerHTML so the\n  // server response replaces the whole control.\n  \"hx-target\"?: string\n  \"hx-swap\"?: string\n  \"hx-trigger\"?: string\n  \"hx-vals\"?: string\n  \"hx-confirm\"?: string\n  // Block double-submits while the toggle request is in flight (v4 name).\n  \"hx-disable\"?: string\n}\n\nexport function OptimisticToggle(props: OptimisticToggleProps) {\n  const {\n    id,\n    pressed,\n    variant = \"default\",\n    size = \"default\",\n    class: className,\n    disabled,\n    ariaLabel,\n    ariaLabelledby,\n    ariaDescribedby,\n    children,\n    optimistic,\n    ...rest\n  } = props\n\n  const templateId = `${id}-optimistic`\n  const classes = optimisticToggleClasses({ variant, size, class: className })\n\n  // hx-target/hx-swap default to replacing the button with the server's\n  // authoritative response on success. data-optimistic points the behaviour\n  // script at the <template> holding the just-toggled markup.\n  const hxTarget = props[\"hx-target\"] ?? \"this\"\n  const hxSwap = props[\"hx-swap\"] ?? \"outerHTML\"\n\n  // Don't leak our defaults twice into ...rest.\n  const { \"hx-target\": _t, \"hx-swap\": _s, ...hxRest } = rest\n\n  return (\n    <span data-slot=\"optimistic-toggle\" class=\"contents\">\n      <button\n        type=\"button\"\n        id={id}\n        class={classes}\n        disabled={disabled}\n        aria-pressed={pressed === undefined ? \"false\" : String(pressed)}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledby}\n        aria-describedby={ariaDescribedby}\n        data-variant={variant}\n        data-size={size}\n        hx-target={hxTarget}\n        hx-swap={hxSwap}\n        data-optimistic={`#${templateId}`}\n        {...hxRest}\n      >\n        {children}\n      </button>\n      {/* Optimistic markup. <template> content is inert until the script clones\n          its innerHTML, so it never renders on its own. The inner state span is\n          tagged data-slot=\"optimistic-toggle-state\" so the script can lift just\n          the icon/label out of it. */}\n      <template id={templateId}>\n        <span\n          data-slot=\"optimistic-toggle-state\"\n          class={cn(classes, \"pointer-events-none\")}\n          aria-pressed=\"true\"\n        >\n          {optimistic}\n        </span>\n      </template>\n      {/* Optimistic-flip + rollback behaviour (attaches once, page-wide). */}\n      <script dangerouslySetInnerHTML={{ __html: OPTIMISTIC_TOGGLE_JS }} />\n    </span>\n  )\n}\n"
    },
    {
      "path": "registry/jinja2/optimistic-toggle.html",
      "type": "registry:file",
      "target": "templates/components/optimistic-toggle.html",
      "content": "{# Optimistic Toggle macro — shadcn-htmx, htmx v4 + Tailwind v4.\n   Mirrors registry/ui/optimistic-toggle.tsx for Python/Flask/FastAPI/Django.\n\n   A server-backed action toggle (like / star / follow / pin). Clicking flips\n   the appearance instantly via a native <template> of the toggled state, then\n   reconciles with the server's HTML response (rolling back on error).\n\n   No htmx extension needed. The behaviour <script> at the bottom of the macro\n   wires the optimistic flip + rollback with real htmx v4 events\n   (htmx:before:request to flip, htmx:before:swap to cancel + roll back on a\n   4xx/5xx). It self-guards with window.__shadcnOptimisticToggle so it only\n   attaches once even if the macro is called many times. (htmx v4's bundled\n   hx-optimistic extension is an unfinished stub that neither flips aria-pressed\n   nor cancels the error swap, so we don't depend on it.)\n\n   A real <button> + aria-pressed follows the APG Button (toggle) pattern: the\n   accessible name stays constant; only aria-pressed flips.\n   repos/aria-practices/content/patterns/button/examples/button.html\n\n   Usage:\n       {% from \"components/optimistic-toggle.html\" import optimistic_toggle %}\n       {% call(state) optimistic_toggle(id=\"like-42\", pressed=is_liked,\n                hx_post=\"/posts/42/like\", aria_label=\"Like\") %}\n         {# state == \"current\" renders the resting label, \"optimistic\" the\n            just-toggled label — call() runs once per state. #}\n         {% if state == \"current\" %}{{ \"Liked\" if is_liked else \"Like\" }}\n         {% else %}Liked{% endif %}\n       {% endcall %}\n\n   All hx-* attributes pass through via **attrs (underscores become dashes). #}\n\n{% macro optimistic_toggle(\n    id,\n    pressed=false,\n    variant=\"default\",\n    size=\"default\",\n    disabled=false,\n    aria_label=none,\n    aria_labelledby=none,\n    aria_describedby=none,\n    hx_target=\"this\",\n    hx_swap=\"outerHTML\",\n    extra_class=\"\",\n    **attrs\n) %}\n{%- set base -%}\ninline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80\n{%- endset -%}\n\n{%- set variants = {\n    \"default\": \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90\",\n    \"outline\": \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:text-primary aria-pressed:bg-primary/10 aria-pressed:hover:bg-primary/15\",\n    \"ghost\": \"hover:bg-accent hover:text-accent-foreground aria-pressed:bg-secondary aria-pressed:text-secondary-foreground aria-pressed:hover:bg-secondary/80\"\n} -%}\n\n{%- set sizes = {\n    \"default\": \"h-9 px-4 py-2 has-[>svg]:px-3\",\n    \"sm\": \"h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5\",\n    \"lg\": \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n    \"icon\": \"size-9\"\n} -%}\n\n{%- set classes = base ~ \" \" ~ variants[variant] ~ \" \" ~ sizes[size] ~ (\" \" ~ extra_class if extra_class else \"\") -%}\n{%- set template_id = id ~ \"-optimistic\" -%}\n\n<span data-slot=\"optimistic-toggle\" class=\"contents\">\n  <button type=\"button\" id=\"{{ id }}\"\n          class=\"{{ classes }}\"\n          {%- if disabled %} disabled{% endif %}\n          aria-pressed=\"{{ 'true' if pressed else 'false' }}\"\n          {%- if aria_label %} aria-label=\"{{ aria_label }}\"{% endif %}\n          {%- if aria_labelledby %} aria-labelledby=\"{{ aria_labelledby }}\"{% endif %}\n          {%- if aria_describedby %} aria-describedby=\"{{ aria_describedby }}\"{% endif %}\n          data-variant=\"{{ variant }}\" data-size=\"{{ size }}\"\n          hx-target=\"{{ hx_target }}\" hx-swap=\"{{ hx_swap }}\"\n          data-optimistic=\"#{{ template_id }}\"\n          {%- for k, v in attrs.items() %} {{ k|replace('_', '-') }}=\"{{ v }}\"{% endfor -%}\n  >{{ caller(\"current\") }}</button>\n  <template id=\"{{ template_id }}\">\n    <span data-slot=\"optimistic-toggle-state\" class=\"{{ classes }} pointer-events-none\" aria-pressed=\"true\">{{ caller(\"optimistic\") }}</span>\n  </template>\n</span>\n{# Optimistic flip + rollback. Self-guarded so it attaches once page-wide. #}\n<script>\n(function(){\n  if (window.__shadcnOptimisticToggle) return;\n  window.__shadcnOptimisticToggle = true;\n  function toggleFor(detail){\n    var ctx = detail && detail.ctx;\n    var src = ctx && ctx.sourceElement;\n    if (!src || !src.closest) return null;\n    var btn = src.closest('[data-slot=\"optimistic-toggle\"] > button[aria-pressed]');\n    return btn || (src.matches && src.matches('button[aria-pressed]') &&\n      src.closest('[data-slot=\"optimistic-toggle\"]') ? src : null);\n  }\n  document.body.addEventListener('htmx:before:request', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn) return;\n    btn.__optHTML = btn.innerHTML;\n    btn.__optPressed = btn.getAttribute('aria-pressed');\n    var sel = btn.getAttribute('data-optimistic');\n    var tmpl = sel && document.querySelector(sel);\n    var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot=\"optimistic-toggle-state\"]') : null;\n    if (inner) btn.innerHTML = inner.innerHTML;\n    btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');\n  }, true);\n  document.body.addEventListener('htmx:before:swap', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn || btn.__optHTML == null) return;\n    var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;\n    if (status >= 400){\n      e.preventDefault();\n      btn.innerHTML = btn.__optHTML;\n      btn.setAttribute('aria-pressed', btn.__optPressed);\n    }\n    btn.__optHTML = null;\n  }, true);\n  document.body.addEventListener('htmx:error', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn || btn.__optHTML == null) return;\n    btn.innerHTML = btn.__optHTML;\n    btn.setAttribute('aria-pressed', btn.__optPressed);\n    btn.__optHTML = null;\n  }, true);\n})();\n</script>\n{% endmacro %}\n"
    },
    {
      "path": "registry/go-templates/optimistic-toggle.tmpl",
      "type": "registry:file",
      "target": "components/optimistic-toggle.tmpl",
      "content": "{{/*\n  Optimistic Toggle template — shadcn-htmx, htmx v4 + Tailwind v4.\n  Mirrors registry/ui/optimistic-toggle.tsx for Go projects using html/template.\n\n  A server-backed action toggle (like / star / follow / pin). Clicking flips\n  the appearance instantly via a native <template> of the toggled state, then\n  reconciles with the server's HTML response (rolling back on error).\n\n  No htmx extension needed. The behaviour <script> at the end of this template\n  wires the optimistic flip + rollback with real htmx v4 events\n  (htmx:before:request to flip, htmx:before:swap to cancel + roll back on a\n  4xx/5xx). It self-guards with window.__shadcnOptimisticToggle so it attaches\n  once page-wide. (htmx v4's bundled hx-optimistic extension is an unfinished\n  stub that neither flips aria-pressed nor cancels the error swap.)\n  A real <button> + aria-pressed follows the APG Button (toggle) pattern: the\n  accessible name stays constant; only aria-pressed flips.\n  repos/aria-practices/content/patterns/button/examples/button.html\n\n  Usage in your code:\n\n      type OptimisticToggleArgs struct {\n          ID         string\n          Pressed    bool\n          Variant    string // default | outline | ghost\n          Size       string // default | sm | lg | icon\n          Disabled   bool\n          AriaLabel  string\n          Current    template.HTML // resting-state inner markup\n          Optimistic template.HTML // just-toggled inner markup\n          HxTarget   string // default \"this\"\n          HxSwap     string // default \"outerHTML\"\n          Attrs      map[string]string // hx-post, hx-confirm, …\n      }\n\n      tpl.ExecuteTemplate(w, \"optimistic-toggle\", OptimisticToggleArgs{\n          ID: \"like-42\", AriaLabel: \"Like\",\n          Current: template.HTML(\"Like\"), Optimistic: template.HTML(\"Liked\"),\n          Attrs: map[string]string{\"hx-post\": \"/posts/42/like\"},\n      })\n\n  Uses sprig's `dict`. Current/Optimistic are rendered with htmlSafe.\n*/}}\n\n{{define \"optimistic-toggle\"}}\n{{- $variants := dict\n    \"default\" \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90\"\n    \"outline\" \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:text-primary aria-pressed:bg-primary/10 aria-pressed:hover:bg-primary/15\"\n    \"ghost\" \"hover:bg-accent hover:text-accent-foreground aria-pressed:bg-secondary aria-pressed:text-secondary-foreground aria-pressed:hover:bg-secondary/80\" -}}\n{{- $sizes := dict\n    \"default\" \"h-9 px-4 py-2 has-[>svg]:px-3\"\n    \"sm\" \"h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5\"\n    \"lg\" \"h-10 rounded-md px-6 has-[>svg]:px-4\"\n    \"icon\" \"size-9\" -}}\n{{- $variant := or .Variant \"default\" -}}\n{{- $size := or .Size \"default\" -}}\n{{- $base := \"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80\" -}}\n{{- $classes := printf \"%s %s %s\" $base (index $variants $variant) (index $sizes $size) -}}\n{{- $target := or .HxTarget \"this\" -}}\n{{- $swap := or .HxSwap \"outerHTML\" -}}\n{{- $templateId := printf \"%s-optimistic\" .ID -}}\n<span data-slot=\"optimistic-toggle\" class=\"contents\">\n  <button type=\"button\" id=\"{{.ID}}\"\n          class=\"{{$classes}}\"\n          {{- if .Disabled}} disabled{{end}}\n          aria-pressed=\"{{if .Pressed}}true{{else}}false{{end}}\"\n          {{- if .AriaLabel}} aria-label=\"{{.AriaLabel}}\"{{end}}\n          {{- if .AriaLabelledby}} aria-labelledby=\"{{.AriaLabelledby}}\"{{end}}\n          {{- if .AriaDescribedby}} aria-describedby=\"{{.AriaDescribedby}}\"{{end}}\n          data-variant=\"{{$variant}}\" data-size=\"{{$size}}\"\n          hx-target=\"{{$target}}\" hx-swap=\"{{$swap}}\"\n          data-optimistic=\"#{{$templateId}}\"\n          {{- range $k, $v := .Attrs}} {{$k}}=\"{{$v}}\"{{end -}}\n  >{{htmlSafe .Current}}</button>\n  <template id=\"{{$templateId}}\">\n    <span data-slot=\"optimistic-toggle-state\" class=\"{{$classes}} pointer-events-none\" aria-pressed=\"true\">{{htmlSafe .Optimistic}}</span>\n  </template>\n</span>\n{{/* Optimistic flip + rollback. Self-guarded so it attaches once page-wide. */}}\n<script>\n(function(){\n  if (window.__shadcnOptimisticToggle) return;\n  window.__shadcnOptimisticToggle = true;\n  function toggleFor(detail){\n    var ctx = detail && detail.ctx;\n    var src = ctx && ctx.sourceElement;\n    if (!src || !src.closest) return null;\n    var btn = src.closest('[data-slot=\"optimistic-toggle\"] > button[aria-pressed]');\n    return btn || (src.matches && src.matches('button[aria-pressed]') &&\n      src.closest('[data-slot=\"optimistic-toggle\"]') ? src : null);\n  }\n  document.body.addEventListener('htmx:before:request', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn) return;\n    btn.__optHTML = btn.innerHTML;\n    btn.__optPressed = btn.getAttribute('aria-pressed');\n    var sel = btn.getAttribute('data-optimistic');\n    var tmpl = sel && document.querySelector(sel);\n    var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot=\"optimistic-toggle-state\"]') : null;\n    if (inner) btn.innerHTML = inner.innerHTML;\n    btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');\n  }, true);\n  document.body.addEventListener('htmx:before:swap', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn || btn.__optHTML == null) return;\n    var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;\n    if (status >= 400){\n      e.preventDefault();\n      btn.innerHTML = btn.__optHTML;\n      btn.setAttribute('aria-pressed', btn.__optPressed);\n    }\n    btn.__optHTML = null;\n  }, true);\n  document.body.addEventListener('htmx:error', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn || btn.__optHTML == null) return;\n    btn.innerHTML = btn.__optHTML;\n    btn.setAttribute('aria-pressed', btn.__optPressed);\n    btn.__optHTML = null;\n  }, true);\n})();\n</script>\n{{end}}\n"
    },
    {
      "path": "registry/phoenix/optimistic_toggle.ex",
      "type": "registry:file",
      "target": "lib/my_app_web/components/optimistic_toggle.ex",
      "content": "defmodule ShadcnHtmx.Components.OptimisticToggle do\n  @moduledoc \"\"\"\n  Optimistic Toggle — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.\n\n  A server-backed action toggle (like / star / follow / pin). Clicking flips\n  the appearance instantly via a native `<template>` of the toggled state, then\n  reconciles with the server's HTML response (rolling back on error).\n\n  Mirrors registry/ui/optimistic-toggle.tsx.\n\n  No htmx extension needed. The behaviour `<script>` rendered with the component\n  wires the optimistic flip + rollback with real htmx v4 events\n  (`htmx:before:request` to flip, `htmx:before:swap` to cancel + roll back on a\n  4xx/5xx). It self-guards with `window.__shadcnOptimisticToggle` so it attaches\n  once page-wide. (htmx v4's bundled `hx-optimistic` extension is an unfinished\n  stub that neither flips `aria-pressed` nor cancels the error swap.)\n\n  A real `<button>` + `aria-pressed` follows the APG Button (toggle) pattern:\n  the accessible name stays constant; only `aria-pressed` flips.\n  repos/aria-practices/content/patterns/button/examples/button.html\n\n  ## Examples\n\n      <.optimistic_toggle id=\"like-42\" pressed={@liked}\n        hx-post=\"/posts/42/like\" aria-label=\"Like\">\n        <:current>{if @liked, do: \"Liked\", else: \"Like\"}</:current>\n        <:optimistic>Liked</:optimistic>\n      </.optimistic_toggle>\n  \"\"\"\n\n  use Phoenix.Component\n\n  @variants %{\n    \"default\" =>\n      \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground \" <>\n        \"aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90\",\n    \"outline\" =>\n      \"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground \" <>\n        \"aria-pressed:border-primary aria-pressed:text-primary aria-pressed:bg-primary/10 aria-pressed:hover:bg-primary/15\",\n    \"ghost\" =>\n      \"hover:bg-accent hover:text-accent-foreground \" <>\n        \"aria-pressed:bg-secondary aria-pressed:text-secondary-foreground aria-pressed:hover:bg-secondary/80\"\n  }\n\n  @sizes %{\n    \"default\" => \"h-9 px-4 py-2 has-[>svg]:px-3\",\n    \"sm\" => \"h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5\",\n    \"lg\" => \"h-10 rounded-md px-6 has-[>svg]:px-4\",\n    \"icon\" => \"size-9\"\n  }\n\n  @base \"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium \" <>\n          \"whitespace-nowrap transition-all outline-none \" <>\n          \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 \" <>\n          \"disabled:pointer-events-none disabled:opacity-50 \" <>\n          \"aria-disabled:pointer-events-none aria-disabled:opacity-50 \" <>\n          \"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 \" <>\n          \"[&.htmx-request]:opacity-80\"\n\n  attr :id, :string, required: true\n  attr :pressed, :boolean, default: false\n\n  attr :variant, :string, default: \"default\", values: ~w(default outline ghost)\n  attr :size, :string, default: \"default\", values: ~w(default sm lg icon)\n\n  attr :disabled, :boolean, default: false\n  attr :class, :string, default: nil\n\n  attr :rest, :global,\n    include:\n      ~w(hx-post hx-put hx-patch hx-delete hx-target hx-swap hx-trigger hx-vals hx-confirm hx-disable\n         aria-label aria-labelledby aria-describedby)\n\n  slot :current, required: true, doc: \"Inner markup for the resting state.\"\n  slot :optimistic, required: true, doc: \"Inner markup for the just-toggled state.\"\n\n  # Optimistic flip + rollback behaviour, using only real htmx v4 events plus\n  # the platform <template> + aria-pressed. Self-guarded so it attaches once\n  # page-wide no matter how many toggles render. Rendered raw inside <script>.\n  @behaviour_js \"\"\"\n  (function(){\n    if (window.__shadcnOptimisticToggle) return;\n    window.__shadcnOptimisticToggle = true;\n    function toggleFor(detail){\n      var ctx = detail && detail.ctx;\n      var src = ctx && ctx.sourceElement;\n      if (!src || !src.closest) return null;\n      var btn = src.closest('[data-slot=\"optimistic-toggle\"] > button[aria-pressed]');\n      return btn || (src.matches && src.matches('button[aria-pressed]') &&\n        src.closest('[data-slot=\"optimistic-toggle\"]') ? src : null);\n    }\n    document.body.addEventListener('htmx:before:request', function(e){\n      var btn = toggleFor(e.detail);\n      if (!btn) return;\n      btn.__optHTML = btn.innerHTML;\n      btn.__optPressed = btn.getAttribute('aria-pressed');\n      var sel = btn.getAttribute('data-optimistic');\n      var tmpl = sel && document.querySelector(sel);\n      var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot=\"optimistic-toggle-state\"]') : null;\n      if (inner) btn.innerHTML = inner.innerHTML;\n      btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');\n    }, true);\n    document.body.addEventListener('htmx:before:swap', function(e){\n      var btn = toggleFor(e.detail);\n      if (!btn || btn.__optHTML == null) return;\n      var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;\n      if (status >= 400){\n        e.preventDefault();\n        btn.innerHTML = btn.__optHTML;\n        btn.setAttribute('aria-pressed', btn.__optPressed);\n      }\n      btn.__optHTML = null;\n    }, true);\n    document.body.addEventListener('htmx:error', function(e){\n      var btn = toggleFor(e.detail);\n      if (!btn || btn.__optHTML == null) return;\n      btn.innerHTML = btn.__optHTML;\n      btn.setAttribute('aria-pressed', btn.__optPressed);\n      btn.__optHTML = null;\n    }, true);\n  })();\n  \"\"\"\n\n  def optimistic_toggle(assigns) do\n    assigns =\n      assigns\n      |> assign(:classes, [@base, Map.fetch!(@variants, assigns.variant), Map.fetch!(@sizes, assigns.size), assigns.class])\n      |> assign(:template_id, \"#{assigns.id}-optimistic\")\n      |> assign(:behaviour_js, Phoenix.HTML.raw(@behaviour_js))\n\n    ~H\"\"\"\n    <span data-slot=\"optimistic-toggle\" class=\"contents\">\n      <button\n        type=\"button\"\n        id={@id}\n        class={@classes}\n        disabled={@disabled}\n        aria-pressed={to_string(@pressed)}\n        data-variant={@variant}\n        data-size={@size}\n        hx-target=\"this\"\n        hx-swap=\"outerHTML\"\n        data-optimistic={\"##{@template_id}\"}\n        {@rest}\n      >\n        {render_slot(@current)}\n      </button>\n      <template id={@template_id}>\n        <span data-slot=\"optimistic-toggle-state\" class={[@classes, \"pointer-events-none\"]} aria-pressed=\"true\">\n          {render_slot(@optimistic)}\n        </span>\n      </template>\n      <%!-- Optimistic flip + rollback. Self-guarded so it attaches once page-wide. --%>\n      <script>{@behaviour_js}</script>\n    </span>\n    \"\"\"\n  end\nend\n"
    },
    {
      "path": "registry/html/optimistic-toggle.html",
      "type": "registry:file",
      "target": "snippets/optimistic-toggle.html",
      "content": "<!--\n  shadcn-htmx — Optimistic Toggle (raw HTML snippet).\n\n  A server-backed action toggle (like / star / follow / pin). Clicking flips\n  the appearance INSTANTLY to the toggled state via a native <template>, then\n  reconciles with the server's HTML response — rolling back automatically if\n  the request fails.\n\n  Requirements:\n    1. Tailwind CSS v4 (the theme tokens --background, --primary, --secondary,\n       --border, --ring, etc.). Copy the :root / .dark blocks from\n       app/styles/input.css.\n    2. htmx v4. NO extension needed — the small behaviour <script> at the\n       bottom of this file wires the optimistic flip + rollback using real htmx\n       v4 events. (htmx v4 ships an unfinished hx-optimistic extension stub that\n       neither flips aria-pressed nor cancels the error swap, so we don't use\n       it.) Load htmx, then this snippet:\n         <script src=\"https://unpkg.com/htmx.org@4.0.0/dist/htmx.min.js\" defer></script>\n\n  How it works:\n    - On htmx:before:request the script saves the button's current innerHTML +\n      aria-pressed, paints the <template>'s \"Liked\" markup in, and flips\n      aria-pressed — the instant pre-network flip.\n    - On success hx-swap=\"outerHTML\" replaces the button with the server's\n      fresh <button> in the new state.\n    - On a 4xx/5xx the script calls preventDefault() in htmx:before:swap to\n      CANCEL the swap (htmx v4 swaps error bodies by default) and restores the\n      saved markup — automatic rollback.\n\n  A real <button> + aria-pressed = the APG Button (toggle) pattern. Keep the\n  accessible name constant across states (aria-pressed carries the state); the\n  <template> below holds the just-toggled appearance and never renders on its\n  own because <template> content is inert until cloned.\n\n  BASE (shared):\n    inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm\n    font-medium whitespace-nowrap transition-all outline-none\n    focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\n    disabled:pointer-events-none disabled:opacity-50\n    aria-disabled:pointer-events-none aria-disabled:opacity-50\n    [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\n    [&.htmx-request]:opacity-80\n-->\n\n<!-- ─── Like toggle (default variant) ──────────────────────────────────── -->\n<!-- Resting state shown; on click the template's \"Liked\" markup flashes in,\n     then the server's fresh button replaces it. -->\n<span data-slot=\"optimistic-toggle\" class=\"contents\">\n  <button type=\"button\" id=\"like-42\"\n          data-variant=\"default\" data-size=\"default\"\n          aria-pressed=\"false\" aria-label=\"Like\"\n          hx-post=\"/posts/42/like\" hx-target=\"this\" hx-swap=\"outerHTML\"\n          data-optimistic=\"#like-42-optimistic\"\n          class=\"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&.htmx-request]:opacity-80 border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground aria-pressed:hover:bg-primary/90 h-9 px-4 py-2 has-[>svg]:px-3\">\n    <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n      <path d=\"M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z\" />\n    </svg>\n    Like\n  </button>\n  <!-- Optimistic (just-toggled) markup — inert until the script clones it. The\n       inner state span is tagged data-slot=\"optimistic-toggle-state\" so the\n       script can lift just the icon/label out of it. -->\n  <template id=\"like-42-optimistic\">\n    <span data-slot=\"optimistic-toggle-state\" aria-pressed=\"true\"\n          class=\"pointer-events-none inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 border bg-background shadow-xs aria-pressed:border-primary aria-pressed:bg-primary aria-pressed:text-primary-foreground h-9 px-4 py-2 has-[>svg]:px-3\">\n      <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"currentColor\" stroke=\"currentColor\" stroke-width=\"2\" stroke-linecap=\"round\" stroke-linejoin=\"round\" aria-hidden=\"true\">\n        <path d=\"M19 14c1.49-1.46 3-3.21 3-5.5A5.5 5.5 0 0 0 16.5 3c-1.76 0-3 .5-4.5 2-1.5-1.5-2.74-2-4.5-2A5.5 5.5 0 0 0 2 8.5c0 2.29 1.51 4.04 3 5.5l7 7Z\" />\n      </svg>\n      Liked\n    </span>\n  </template>\n</span>\n\n<!-- The server should reply with a fresh <button> in the new state, e.g.: -->\n<!--\n<span data-slot=\"optimistic-toggle\" class=\"contents\">\n  <button type=\"button\" id=\"like-42\" aria-pressed=\"true\" aria-label=\"Like\"\n          hx-post=\"/posts/42/unlike\" hx-target=\"this\" hx-swap=\"outerHTML\"\n          data-optimistic=\"#like-42-optimistic\"\n          class=\"… aria-pressed:bg-primary aria-pressed:text-primary-foreground …\">\n    <svg … fill=\"currentColor\">…</svg> Liked\n  </button>\n  <template id=\"like-42-optimistic\">…unlike preview…</template>\n</span>\n-->\n\n<!-- ─── Behaviour: optimistic flip + rollback (copy once, e.g. into site.js) ─\n     Uses only real htmx v4 events + <template> + aria-pressed. No extension. -->\n<script>\n(function(){\n  if (window.__shadcnOptimisticToggle) return;\n  window.__shadcnOptimisticToggle = true;\n\n  function toggleFor(detail){\n    var ctx = detail && detail.ctx;\n    var src = ctx && ctx.sourceElement;\n    if (!src || !src.closest) return null;\n    var btn = src.closest('[data-slot=\"optimistic-toggle\"] > button[aria-pressed]');\n    return btn || (src.matches && src.matches('button[aria-pressed]') &&\n      src.closest('[data-slot=\"optimistic-toggle\"]') ? src : null);\n  }\n\n  document.body.addEventListener('htmx:before:request', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn) return;\n    btn.__optHTML = btn.innerHTML;\n    btn.__optPressed = btn.getAttribute('aria-pressed');\n    var sel = btn.getAttribute('data-optimistic');\n    var tmpl = sel && document.querySelector(sel);\n    var inner = tmpl && tmpl.content ? tmpl.content.querySelector('[data-slot=\"optimistic-toggle-state\"]') : null;\n    if (inner) btn.innerHTML = inner.innerHTML;\n    btn.setAttribute('aria-pressed', btn.__optPressed === 'true' ? 'false' : 'true');\n  }, true);\n\n  document.body.addEventListener('htmx:before:swap', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn || btn.__optHTML == null) return;\n    var status = e.detail && e.detail.ctx && e.detail.ctx.response && e.detail.ctx.response.status;\n    if (status >= 400){\n      e.preventDefault();\n      btn.innerHTML = btn.__optHTML;\n      btn.setAttribute('aria-pressed', btn.__optPressed);\n    }\n    btn.__optHTML = null;\n  }, true);\n\n  document.body.addEventListener('htmx:error', function(e){\n    var btn = toggleFor(e.detail);\n    if (!btn || btn.__optHTML == null) return;\n    btn.innerHTML = btn.__optHTML;\n    btn.setAttribute('aria-pressed', btn.__optPressed);\n    btn.__optHTML = null;\n  }, true);\n})();\n</script>\n"
    }
  ]
}
