{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "name": "listbox",
  "type": "registry:ui",
  "title": "Listbox",
  "description": "A scrollable single- or multi-select list built on role=\"listbox\" with role=\"option\" children, following the WAI-ARIA APG keyboard contract. A hidden input mirrors the selection so it submits like a normal form field.",
  "registryDependencies": [
    "utils"
  ],
  "files": [
    {
      "path": "registry/ui/listbox.tsx",
      "type": "registry:ui",
      "target": "components/ui/listbox.tsx",
      "content": "/** @jsxImportSource hono/jsx */\nimport type { PropsWithChildren } from \"hono/jsx\"\nimport { cn, type ClassValue } from \"@/registry/lib/cn\"\n\n// Listbox — shadcn-htmx, htmx v4 + Tailwind v4.\n//\n// shadcn/ui builds its rich Select on a Radix popover with role=\"listbox\"\n// options. We split that into two components: `Select` is the truly-native\n// dropdown (<select>), and this `Listbox` is the always-visible, scrollable,\n// single/multi-select widget — the APG Listbox pattern. We mirror shadcn's\n// anatomy (a container + option children) but translate it to a real\n// role=\"listbox\" / role=\"option\" tree, not a Radix portal.\n//   Anatomy reference (intent only): repos/shadcn-ui/apps/v4/registry/new-york-v4/ui/select.tsx\n//\n// Accessibility contract follows the WAI-ARIA APG Listbox pattern:\n//   repos/aria-practices/content/patterns/listbox/listbox-pattern.html\n//     (roles/states: listbox, option, aria-selected, aria-multiselectable,\n//      aria-orientation; keyboard: Up/Down/Home/End, Space/Enter, type-ahead,\n//      Shift/Ctrl range + Ctrl+A for multi-select)\n//   repos/aria-practices/content/patterns/listbox/examples/listbox-scrollable.html\n//     (the scrollable single-select example whose markup we mirror: a <ul>\n//      with role=\"listbox\" holding <li role=\"option\"> children)\n//\n// Focus management — roving tabindex (NOT aria-activedescendant). The APG\n// permits either; the rest of this library (Toolbar, Tabs, Menu) uses a\n// roving tabindex, so we keep DOM focus on the options for consistency. The\n// container <ul> is tabindex=\"-1\"; exactly one <li role=\"option\"> carries\n// tabindex=\"0\" (the first selected option, else the first option) and the\n// rest tabindex=\"-1\". An inline boot <script> sets that before paint (no\n// flicker); public/site.js (keyed on data-slot=\"listbox\") owns the live\n// keyboard + selection contract and keeps the hidden form input in sync.\n//   Roving tabindex rationale: repos/aria-practices/content/practices/keyboard-interface/keyboard-interface-practice.html\n//\n// Selection state uses aria-selected (APG's recommended convention for\n// single-select; we keep it for multi-select too so the markup is uniform).\n// The container sets aria-multiselectable=\"true\" when multiple.\n//   repos/mdn/files/en-us/web/accessibility/aria/reference/roles/listbox_role/index.md\n//   repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-selected/index.md\n//\n// Form association: the styled listbox is a custom widget, so it does not\n// submit on its own like a control. We render a sibling <input type=\"hidden\">\n// whose value mirrors the selection (a single value, or a comma-joined list\n// when multiple) and keep it current from the boot script + site.js. For a\n// zero-JS alternative, a native <select multiple> is the platform's listbox\n// (it submits each selected <option> automatically) — use it when you don't\n// need custom option rendering. See repos/mdn/files/en-us/web/html/reference/elements/select/\n\nexport type ListboxOrientation = \"horizontal\" | \"vertical\"\n\nconst containerBase =\n  \"max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none \" +\n  \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 \" +\n  \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50 \" +\n  \"data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden\"\n\nconst optionBase =\n  \"relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none \" +\n  \"hover:bg-accent hover:text-accent-foreground \" +\n  \"focus-visible:bg-accent focus-visible:text-accent-foreground \" +\n  // Selected option gets the primary fill; matches how shadcn marks a chosen item.\n  \"aria-selected:bg-primary aria-selected:text-primary-foreground \" +\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\nexport function listboxClasses(opts?: { class?: ClassValue }): string {\n  return cn(containerBase, opts?.class)\n}\n\nexport function listboxOptionClasses(opts?: { class?: ClassValue }): string {\n  return cn(optionBase, opts?.class)\n}\n\ntype ListboxProps = PropsWithChildren<{\n  // Required when there's no visible label so AT can name the listbox.\n  // APG: a standalone listbox must be labelled via aria-label or aria-labelledby.\n  ariaLabel?: string\n  ariaLabelledby?: string\n  // Allow choosing more than one option. Sets aria-multiselectable=\"true\".\n  multiple?: boolean\n  // Disable the whole widget (sets aria-disabled; options become inert).\n  disabled?: boolean\n  // Group-level requirement: \"one option must be chosen\". Sets aria-required\n  // on the container. The styled listbox submits via a hidden input, so the\n  // native `required` attribute cannot apply — aria-required is the only way\n  // to convey it. Mirrors RadioGroup's `required` -> aria-required.\n  //   repos/mdn/files/en-us/web/accessibility/aria/reference/roles/listbox_role/index.md (aria-required under States and Properties)\n  required?: boolean\n  // Mark the widget invalid for the WCAG error-identification pattern (e.g. a\n  // required listbox submitted empty, or server validation over htmx). Pair\n  // with ariaErrormessage pointing at a visible error element's id.\n  //   repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-invalid/index.md (listbox in Associated roles)\n  ariaInvalid?: boolean\n  // Id of a visible element holding the error message. Pair with ariaInvalid.\n  //   repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-errormessage/index.md (listbox in Associated roles)\n  ariaErrormessage?: string\n  // Locked-but-operable: the user cannot change the selection but the listbox\n  // stays focusable/navigable. Distinct from `disabled` (which makes options\n  // inert). Sets aria-readonly; mirrors Checkbox's `ariaReadonly`.\n  //   repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-readonly/index.md (listbox in Associated roles)\n  ariaReadonly?: boolean\n  // Layout axis. Default vertical; \"horizontal\" sets aria-orientation and\n  // flips the arrow-key axis (Left/Right) in site.js.\n  orientation?: ListboxOrientation\n  // Name of the hidden form input that mirrors the selection. Omit to skip\n  // the hidden input entirely (e.g. when you drive selection over htmx).\n  name?: string\n  id?: string\n  class?: ClassValue\n  // htmx + arbitrary attributes ride onto the listbox container. Typical:\n  //   hx-post=\"/save\" hx-trigger=\"listbox:change\" (fired by site.js).\n  [key: `hx-${string}`]: any\n  [key: `data-${string}`]: any\n  [key: `aria-${string}`]: any\n}>\n\nexport function Listbox(props: ListboxProps) {\n  const {\n    ariaLabel,\n    ariaLabelledby,\n    multiple,\n    disabled,\n    required,\n    ariaInvalid,\n    ariaErrormessage,\n    ariaReadonly,\n    orientation = \"vertical\",\n    name,\n    class: className,\n    children,\n    ...rest\n  } = props as any\n  // Boot script: establish the roving tabindex before paint (single tab stop),\n  // and seed the hidden form input from the initial aria-selected options.\n  // The first selected option (else the first option) gets tabindex=\"0\".\n  const boot = `(function(el){\n    var opts = el.querySelectorAll('[role=\"option\"]');\n    var sel = el.querySelector('[role=\"option\"][aria-selected=\"true\"]:not([aria-disabled=\"true\"])');\n    var active = sel || el.querySelector('[role=\"option\"]:not([aria-disabled=\"true\"])');\n    opts.forEach(function(o){ o.setAttribute('tabindex', o === active ? '0' : '-1'); });\n    var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]');\n    if (hidden) {\n      var vals = [];\n      el.querySelectorAll('[role=\"option\"][aria-selected=\"true\"]').forEach(function(o){\n        vals.push(o.getAttribute('data-value') || (o.textContent || '').trim());\n      });\n      hidden.value = vals.join(',');\n    }\n    el.setAttribute('data-listbox-ready','true');\n  })(document.currentScript.parentNode.querySelector('[data-slot=\"listbox\"]'));`\n  return (\n    <span class=\"relative inline-flex w-full flex-col\">\n      <ul\n        role=\"listbox\"\n        data-slot=\"listbox\"\n        data-orientation={orientation}\n        aria-orientation={orientation}\n        aria-multiselectable={multiple ? \"true\" : undefined}\n        aria-label={ariaLabel}\n        aria-labelledby={ariaLabelledby}\n        aria-disabled={disabled ? \"true\" : undefined}\n        // Group-level \"one must be chosen\" requirement (listbox supports aria-required).\n        aria-required={required ? \"true\" : undefined}\n        aria-invalid={ariaInvalid === undefined ? undefined : String(ariaInvalid)}\n        aria-errormessage={ariaErrormessage}\n        // Locked-but-operable, distinct from aria-disabled.\n        aria-readonly={ariaReadonly ? \"true\" : undefined}\n        // The container is not in the tab order; focus lands on the options\n        // (roving tabindex). tabindex=\"-1\" lets us still programmatically\n        // focus the list itself if needed without adding a tab stop.\n        tabindex={-1}\n        class={listboxClasses({ class: className })}\n        {...rest}\n      >\n        {children}\n      </ul>\n      {/* Hidden form value — mirrors the selection so the listbox submits\n          like a normal field. site.js keeps it in sync on every change. */}\n      {name ? (\n        <input type=\"hidden\" name={name} data-listbox-value=\"\" />\n      ) : null}\n      <script\n        // biome-ignore lint/security/noDangerouslySetInnerHtml: SSR boot\n        dangerouslySetInnerHTML={{ __html: boot }}\n      />\n    </span>\n  )\n}\n\ntype ListboxOptionProps = PropsWithChildren<{\n  // The value submitted via the hidden input when selected. Defaults to the\n  // option's text content when omitted.\n  value?: string\n  // Pre-select this option. In a single-select listbox only one should be set.\n  selected?: boolean\n  // Keep the option in the list (and announced by AT) but unselectable.\n  disabled?: boolean\n  id?: string\n  class?: ClassValue\n  [key: `data-${string}`]: any\n  [key: `aria-${string}`]: any\n}>\n\nexport function ListboxOption(props: ListboxOptionProps) {\n  const {\n    value,\n    selected,\n    disabled,\n    class: className,\n    children,\n    ...rest\n  } = props as any\n  return (\n    <li\n      role=\"option\"\n      data-slot=\"listbox-option\"\n      data-value={value}\n      // Every selectable option carries aria-selected (true/false) per APG;\n      // disabled options stay in the tree but are aria-disabled.\n      aria-selected={disabled ? undefined : selected ? \"true\" : \"false\"}\n      aria-disabled={disabled ? \"true\" : undefined}\n      // tabindex is assigned by the boot script / site.js (roving tabindex).\n      class={listboxOptionClasses({ class: className })}\n      {...rest}\n    >\n      {children}\n    </li>\n  )\n}\n\n// A labelled cluster of options. role=\"group\" lets AT announce the group\n// name without adding a tab stop; the options inside still participate in\n// the listbox's single roving tabindex.\n//   repos/aria-practices/content/patterns/listbox/examples/listbox-grouped.html\ntype ListboxGroupProps = PropsWithChildren<{\n  // Visible/accessible name for the group. APG requires grouped options to\n  // have an accessible name via aria-label or aria-labelledby.\n  label: string\n  class?: ClassValue\n}>\n\nexport function ListboxGroup(props: ListboxGroupProps) {\n  const { label, class: className, children } = props\n  return (\n    <li role=\"presentation\" data-slot=\"listbox-group-wrapper\" class={cn(\"py-1\", className)}>\n      <span\n        aria-hidden=\"true\"\n        class=\"px-2 py-1 text-xs font-medium text-muted-foreground\"\n      >\n        {label}\n      </span>\n      <ul role=\"group\" data-slot=\"listbox-group\" aria-label={label} class=\"contents\">\n        {children}\n      </ul>\n    </li>\n  )\n}\n"
    },
    {
      "path": "registry/jinja2/listbox.html",
      "type": "registry:file",
      "target": "templates/components/listbox.html",
      "content": "{# Listbox macros — shadcn-htmx, htmx v4 + Tailwind v4.\n   Mirrors registry/ui/listbox.tsx. Renders the APG Listbox pattern: a\n   <ul role=\"listbox\"> with <li role=\"option\"> children, a sibling hidden\n   <input> for form submission, and a boot <script> that sets the roving\n   tabindex (single tab stop) + seeds the hidden value on first paint.\n   public/site.js (keyed on data-slot=\"listbox\") owns the live keyboard +\n   selection contract. Accessibility contract:\n     repos/aria-practices/content/patterns/listbox/listbox-pattern.html\n\n   Usage:\n     {% from \"components/listbox.html\" import listbox_open, listbox_close,\n          listbox_option, listbox_group_open, listbox_group_close %}\n\n     {{ listbox_open(aria_label=\"Favourite element\", name=\"element\") }}\n       {{ listbox_option(\"Hydrogen\", value=\"H\", selected=true) }}\n       {{ listbox_option(\"Helium\",   value=\"He\") }}\n       {{ listbox_option(\"Lithium\",  value=\"Li\") }}\n     {{ listbox_close(name=\"element\") }} #}\n\n{% macro listbox_open(aria_label=none, aria_labelledby=none, multiple=false, disabled=false, required=false, aria_invalid=none, aria_errormessage=none, aria_readonly=false, orientation=\"vertical\", name=none, extra_class=\"\", attrs={}) -%}\n<span class=\"relative inline-flex w-full flex-col\">\n<ul role=\"listbox\"\n    data-slot=\"listbox\"\n    data-orientation=\"{{ orientation }}\"\n    aria-orientation=\"{{ orientation }}\"\n    {%- if multiple %} aria-multiselectable=\"true\"{% endif %}\n    {%- if aria_label %} aria-label=\"{{ aria_label }}\"{% endif %}\n    {%- if aria_labelledby %} aria-labelledby=\"{{ aria_labelledby }}\"{% endif %}\n    {%- if disabled %} aria-disabled=\"true\"{% endif %}\n    {#- Group-level \"one must be chosen\" requirement (listbox supports aria-required). #}\n    {%- if required %} aria-required=\"true\"{% endif %}\n    {#- WCAG error-identification: aria-invalid + aria-errormessage point at a visible error. #}\n    {%- if aria_invalid is not none %} aria-invalid=\"{{ 'true' if aria_invalid else 'false' }}\"{% endif %}\n    {%- if aria_errormessage %} aria-errormessage=\"{{ aria_errormessage }}\"{% endif %}\n    {#- Locked-but-operable, distinct from aria-disabled. #}\n    {%- if aria_readonly %} aria-readonly=\"true\"{% endif %}\n    tabindex=\"-1\"\n    {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}=\"{{ v }}\"{% endfor %}\n    class=\"max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden {{ extra_class }}\">\n{%- endmacro %}\n\n{% macro listbox_close(name=none) -%}\n</ul>\n{%- if name %}\n<input type=\"hidden\" name=\"{{ name }}\" data-listbox-value=\"\">\n{%- endif %}\n<script>(function(el){\n  var opts = el.querySelectorAll('[role=\"option\"]');\n  var sel = el.querySelector('[role=\"option\"][aria-selected=\"true\"]:not([aria-disabled=\"true\"])');\n  var active = sel || el.querySelector('[role=\"option\"]:not([aria-disabled=\"true\"])');\n  opts.forEach(function(o){ o.setAttribute('tabindex', o === active ? '0' : '-1'); });\n  var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]');\n  if (hidden) {\n    var vals = [];\n    el.querySelectorAll('[role=\"option\"][aria-selected=\"true\"]').forEach(function(o){\n      vals.push(o.getAttribute('data-value') || (o.textContent || '').trim());\n    });\n    hidden.value = vals.join(',');\n  }\n  el.setAttribute('data-listbox-ready','true');\n})(document.currentScript.parentNode.querySelector('[data-slot=\"listbox\"]'));</script>\n</span>\n{%- endmacro %}\n\n{% macro listbox_option(label, value=none, selected=false, disabled=false, id=none, extra_class=\"\", attrs={}) -%}\n<li role=\"option\"\n    data-slot=\"listbox-option\"\n    {%- if value is not none %} data-value=\"{{ value }}\"{% endif %}\n    {%- if disabled %} aria-disabled=\"true\"{% else %} aria-selected=\"{{ 'true' if selected else 'false' }}\"{% endif %}\n    {%- if id %} id=\"{{ id }}\"{% endif %}\n    {%- for k, v in attrs.items() %} {{ k|replace('_','-') }}=\"{{ v }}\"{% endfor %}\n    class=\"relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 {{ extra_class }}\">{{ label }}</li>\n{%- endmacro %}\n\n{# A labelled cluster of options. role=\"group\" carries the accessible name;\n   options inside still participate in the listbox's roving tabindex. #}\n{% macro listbox_group_open(label, extra_class=\"\") -%}\n<li role=\"presentation\" data-slot=\"listbox-group-wrapper\" class=\"py-1 {{ extra_class }}\">\n  <span aria-hidden=\"true\" class=\"px-2 py-1 text-xs font-medium text-muted-foreground\">{{ label }}</span>\n  <ul role=\"group\" data-slot=\"listbox-group\" aria-label=\"{{ label }}\" class=\"contents\">\n{%- endmacro %}\n{% macro listbox_group_close() %}</ul></li>{% endmacro %}\n"
    },
    {
      "path": "registry/go-templates/listbox.tmpl",
      "type": "registry:file",
      "target": "components/listbox.tmpl",
      "content": "{{/*\n  Listbox template — shadcn-htmx, htmx v4 + Tailwind v4.\n  Mirrors registry/ui/listbox.tsx. Named templates:\n    - \"listbox\"           — wrapper (<span> + <ul role=\"listbox\">) + hidden\n                            input + boot script. Pass .Body (template.HTML)\n                            with the composed options/groups.\n    - \"listbox_option\"    — one <li role=\"option\">\n    - \"listbox_group\"     — a labelled role=\"group\" cluster (pass .Body)\n\n  The boot script sets the roving tabindex (single tab stop) + seeds the\n  hidden form value on first paint; public/site.js (keyed on\n  data-slot=\"listbox\") owns the live keyboard + selection contract.\n  Accessibility contract:\n    repos/aria-practices/content/patterns/listbox/listbox-pattern.html\n\n  Hand-compose the inner HTML (options/groups) and pass it as .Body\n  (template.HTML / htmlSafe). Set .Name to render the hidden form input.\n*/}}\n\n{{define \"listbox\"}}\n{{- $orientation := or .Orientation \"vertical\" -}}\n<span class=\"relative inline-flex w-full flex-col\">\n<ul role=\"listbox\"\n    data-slot=\"listbox\"\n    data-orientation=\"{{$orientation}}\"\n    aria-orientation=\"{{$orientation}}\"\n    {{- if .Multiple}} aria-multiselectable=\"true\"{{end}}\n    {{- if .AriaLabel}} aria-label=\"{{.AriaLabel}}\"{{end}}\n    {{- if .AriaLabelledby}} aria-labelledby=\"{{.AriaLabelledby}}\"{{end}}\n    {{- if .Disabled}} aria-disabled=\"true\"{{end}}\n    {{- /* Group-level \"one must be chosen\" requirement (listbox supports aria-required). */}}\n    {{- if .Required}} aria-required=\"true\"{{end}}\n    {{- /* WCAG error-identification: aria-invalid + aria-errormessage point at a visible error. */}}\n    {{- if .AriaInvalid}} aria-invalid=\"true\"{{end}}\n    {{- if .AriaErrormessage}} aria-errormessage=\"{{.AriaErrormessage}}\"{{end}}\n    {{- /* Locked-but-operable, distinct from aria-disabled. */}}\n    {{- if .AriaReadonly}} aria-readonly=\"true\"{{end}}\n    tabindex=\"-1\"\n    class=\"max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden\">\n  {{.Body}}\n</ul>\n{{- if .Name}}\n<input type=\"hidden\" name=\"{{.Name}}\" data-listbox-value=\"\">\n{{- end}}\n<script>(function(el){\n  var opts = el.querySelectorAll('[role=\"option\"]');\n  var sel = el.querySelector('[role=\"option\"][aria-selected=\"true\"]:not([aria-disabled=\"true\"])');\n  var active = sel || el.querySelector('[role=\"option\"]:not([aria-disabled=\"true\"])');\n  opts.forEach(function(o){ o.setAttribute('tabindex', o === active ? '0' : '-1'); });\n  var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]');\n  if (hidden) {\n    var vals = [];\n    el.querySelectorAll('[role=\"option\"][aria-selected=\"true\"]').forEach(function(o){\n      vals.push(o.getAttribute('data-value') || (o.textContent || '').trim());\n    });\n    hidden.value = vals.join(',');\n  }\n  el.setAttribute('data-listbox-ready','true');\n})(document.currentScript.parentNode.querySelector('[data-slot=\"listbox\"]'));</script>\n</span>\n{{end}}\n\n{{define \"listbox_option\"}}\n<li role=\"option\"\n    data-slot=\"listbox-option\"\n    {{- if .Value}} data-value=\"{{.Value}}\"{{end}}\n    {{- if .Disabled}} aria-disabled=\"true\"{{else}} aria-selected=\"{{if .Selected}}true{{else}}false{{end}}\"{{end}}\n    {{- if .ID}} id=\"{{.ID}}\"{{end}}\n    class=\"relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\">{{.Label}}</li>\n{{end}}\n\n{{define \"listbox_group\"}}\n<li role=\"presentation\" data-slot=\"listbox-group-wrapper\" class=\"py-1\">\n  <span aria-hidden=\"true\" class=\"px-2 py-1 text-xs font-medium text-muted-foreground\">{{.Label}}</span>\n  <ul role=\"group\" data-slot=\"listbox-group\" aria-label=\"{{.Label}}\" class=\"contents\">\n    {{.Body}}\n  </ul>\n</li>\n{{end}}\n"
    },
    {
      "path": "registry/phoenix/listbox.ex",
      "type": "registry:file",
      "target": "lib/my_app_web/components/listbox.ex",
      "content": "defmodule ShadcnHtmx.Components.Listbox do\n  @moduledoc \"\"\"\n  Listbox — shadcn-htmx, htmx v4 + Tailwind v4 for Phoenix.\n\n  Mirrors registry/ui/listbox.tsx. Function components: `listbox`,\n  `listbox_option`, `listbox_group`.\n\n  Renders the APG Listbox pattern: a `<ul role=\"listbox\">` with\n  `<li role=\"option\">` children, a sibling hidden `<input>` for form\n  submission, and a boot `<script>` that sets the roving tabindex (single\n  tab stop) + seeds the hidden value on first paint. public/site.js (keyed\n  on data-slot=\"listbox\") owns the live keyboard + selection contract.\n  Accessibility contract:\n  repos/aria-practices/content/patterns/listbox/listbox-pattern.html\n\n  ## Examples\n\n      <.listbox aria-label=\"Favourite element\" name=\"element\">\n        <.listbox_option value=\"H\" selected>Hydrogen</.listbox_option>\n        <.listbox_option value=\"He\">Helium</.listbox_option>\n        <.listbox_option value=\"Li\">Lithium</.listbox_option>\n      </.listbox>\n  \"\"\"\n\n  use Phoenix.Component\n\n  attr :orientation, :string, default: \"vertical\", values: ~w(horizontal vertical)\n  attr :multiple, :boolean, default: false\n  attr :disabled, :boolean, default: false\n  # Group-level \"one must be chosen\" requirement (listbox supports aria-required).\n  # repos/mdn/files/en-us/web/accessibility/aria/reference/roles/listbox_role/index.md\n  attr :required, :boolean, default: false\n  # WCAG error-identification: aria-invalid + aria-errormessage point at a visible error.\n  # repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-invalid/index.md\n  attr :\"aria-invalid\", :boolean, default: nil\n  attr :\"aria-errormessage\", :string, default: nil\n  # Locked-but-operable, distinct from aria-disabled.\n  # repos/mdn/files/en-us/web/accessibility/aria/reference/attributes/aria-readonly/index.md\n  attr :\"aria-readonly\", :boolean, default: false\n  attr :name, :string, default: nil\n  attr :\"aria-label\", :string, default: nil\n  attr :\"aria-labelledby\", :string, default: nil\n  attr :class, :string, default: nil\n  attr :rest, :global\n  slot :inner_block, required: true\n\n  def listbox(assigns) do\n    ~H\"\"\"\n    <span class=\"relative inline-flex w-full flex-col\">\n      <ul\n        role=\"listbox\"\n        data-slot=\"listbox\"\n        data-orientation={@orientation}\n        aria-orientation={@orientation}\n        aria-multiselectable={@multiple && \"true\"}\n        aria-label={assigns[:\"aria-label\"]}\n        aria-labelledby={assigns[:\"aria-labelledby\"]}\n        aria-disabled={@disabled && \"true\"}\n        aria-required={@required && \"true\"}\n        aria-invalid={if assigns[:\"aria-invalid\"] == nil, do: nil, else: to_string(assigns[:\"aria-invalid\"])}\n        aria-errormessage={assigns[:\"aria-errormessage\"]}\n        aria-readonly={assigns[:\"aria-readonly\"] && \"true\"}\n        tabindex=\"-1\"\n        class={[\n          \"max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none\",\n          \"focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50\",\n          \"aria-disabled:cursor-not-allowed aria-disabled:opacity-50\",\n          \"data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden\",\n          @class\n        ]}\n        {@rest}\n      >\n        {render_slot(@inner_block)}\n      </ul>\n      <input :if={@name} type=\"hidden\" name={@name} data-listbox-value=\"\" />\n      <script>{Phoenix.HTML.raw(~s\"\"\"\n        (function(el){\n          var opts = el.querySelectorAll('[role=\"option\"]');\n          var sel = el.querySelector('[role=\"option\"][aria-selected=\"true\"]:not([aria-disabled=\"true\"])');\n          var active = sel || el.querySelector('[role=\"option\"]:not([aria-disabled=\"true\"])');\n          opts.forEach(function(o){ o.setAttribute('tabindex', o === active ? '0' : '-1'); });\n          var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]');\n          if (hidden) {\n            var vals = [];\n            el.querySelectorAll('[role=\"option\"][aria-selected=\"true\"]').forEach(function(o){\n              vals.push(o.getAttribute('data-value') || (o.textContent || '').trim());\n            });\n            hidden.value = vals.join(',');\n          }\n          el.setAttribute('data-listbox-ready','true');\n        })(document.currentScript.parentNode.querySelector('[data-slot=\"listbox\"]'));\n      \"\"\")}</script>\n    </span>\n    \"\"\"\n  end\n\n  attr :value, :string, default: nil\n  attr :selected, :boolean, default: false\n  attr :disabled, :boolean, default: false\n  attr :id, :string, default: nil\n  attr :class, :string, default: nil\n  attr :rest, :global\n  slot :inner_block, required: true\n\n  def listbox_option(assigns) do\n    ~H\"\"\"\n    <li\n      role=\"option\"\n      data-slot=\"listbox-option\"\n      data-value={@value}\n      aria-selected={!@disabled && (if @selected, do: \"true\", else: \"false\")}\n      aria-disabled={@disabled && \"true\"}\n      id={@id}\n      class={[\n        \"relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none\",\n        \"hover:bg-accent hover:text-accent-foreground\",\n        \"focus-visible:bg-accent focus-visible:text-accent-foreground\",\n        \"aria-selected:bg-primary aria-selected:text-primary-foreground\",\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        @class\n      ]}\n      {@rest}\n    >\n      {render_slot(@inner_block)}\n    </li>\n    \"\"\"\n  end\n\n  attr :label, :string, required: true\n  attr :class, :string, default: nil\n  slot :inner_block, required: true\n\n  def listbox_group(assigns) do\n    ~H\"\"\"\n    <li role=\"presentation\" data-slot=\"listbox-group-wrapper\" class={[\"py-1\", @class]}>\n      <span aria-hidden=\"true\" class=\"px-2 py-1 text-xs font-medium text-muted-foreground\">\n        {@label}\n      </span>\n      <ul role=\"group\" data-slot=\"listbox-group\" aria-label={@label} class=\"contents\">\n        {render_slot(@inner_block)}\n      </ul>\n    </li>\n    \"\"\"\n  end\nend\n"
    },
    {
      "path": "registry/html/listbox.html",
      "type": "registry:file",
      "target": "snippets/listbox.html",
      "content": "<!--\n  shadcn-htmx — raw HTML listbox snippet.\n\n  Mirrors registry/ui/listbox.tsx. The APG Listbox pattern: a\n  <ul role=\"listbox\"> with <li role=\"option\"> children. The inline <script>\n  right after the wrapper sets the roving tabindex (single tab stop) and\n  seeds the hidden form value on first paint (no flicker). The live keyboard\n  + selection contract (Up/Down/Home/End, Space/Enter, type-ahead, multi-\n  select range) needs the wiring in public/site.js, keyed on\n  data-slot=\"listbox\".\n\n  Accessibility contract:\n    repos/aria-practices/content/patterns/listbox/listbox-pattern.html\n\n  Optional state attributes on the <ul role=\"listbox\"> (add as needed):\n    aria-required=\"true\"           group-level \"one option must be chosen\"\n                                   (listbox supports aria-required)\n    aria-invalid=\"true\"            mark invalid for the WCAG error-id pattern\n    aria-errormessage=\"errId\"      id of the visible error message element\n    aria-readonly=\"true\"           locked-but-operable (focusable/navigable,\n                                   selection cannot change) — distinct from\n                                   aria-disabled which makes options inert\n    See repos/mdn/files/en-us/web/accessibility/aria/reference/roles/listbox_role/index.md\n\n  Required CSS theme variables: --background, --foreground, --primary,\n  --primary-foreground, --accent, --accent-foreground, --muted-foreground,\n  --border, --ring. See app/styles/input.css.\n\n  Native fallback (zero JS): a <select multiple> IS the platform's listbox —\n  it submits each selected <option> on its own. Use it when you don't need\n  custom option rendering. The styled widget below is the APG listbox.\n-->\n\n<span class=\"relative inline-flex w-full flex-col\">\n  <ul id=\"fav-element\"\n      role=\"listbox\"\n      data-slot=\"listbox\"\n      data-orientation=\"vertical\"\n      aria-orientation=\"vertical\"\n      aria-label=\"Favourite element\"\n      tabindex=\"-1\"\n      class=\"max-h-60 w-full overflow-y-auto overflow-x-hidden rounded-md border bg-background p-1 text-sm shadow-xs outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-disabled:cursor-not-allowed aria-disabled:opacity-50 data-[orientation=horizontal]:flex data-[orientation=horizontal]:max-h-none data-[orientation=horizontal]:overflow-x-auto data-[orientation=horizontal]:overflow-y-hidden\">\n\n    <li role=\"option\" data-slot=\"listbox-option\" data-value=\"H\" aria-selected=\"true\"\n        class=\"relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\">Hydrogen</li>\n\n    <li role=\"option\" data-slot=\"listbox-option\" data-value=\"He\" aria-selected=\"false\"\n        class=\"relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\">Helium</li>\n\n    <li role=\"option\" data-slot=\"listbox-option\" data-value=\"Li\" aria-selected=\"false\"\n        class=\"relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\">Lithium</li>\n\n    <li role=\"option\" data-slot=\"listbox-option\" data-value=\"Be\" aria-disabled=\"true\"\n        class=\"relative flex cursor-pointer scroll-my-1 items-center gap-2 rounded-sm px-2 py-1.5 text-foreground outline-none select-none hover:bg-accent hover:text-accent-foreground focus-visible:bg-accent focus-visible:text-accent-foreground aria-selected:bg-primary aria-selected:text-primary-foreground aria-disabled:pointer-events-none aria-disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4\">Beryllium</li>\n\n  </ul>\n\n  <!-- Hidden form value — site.js keeps it in sync with the selection. -->\n  <input type=\"hidden\" name=\"element\" data-listbox-value=\"\">\n\n  <script>\n    (function (el) {\n      var opts = el.querySelectorAll('[role=\"option\"]')\n      var sel = el.querySelector('[role=\"option\"][aria-selected=\"true\"]:not([aria-disabled=\"true\"])')\n      var active = sel || el.querySelector('[role=\"option\"]:not([aria-disabled=\"true\"])')\n      opts.forEach(function (o) { o.setAttribute('tabindex', o === active ? '0' : '-1') })\n      var hidden = el.parentNode && el.parentNode.querySelector('[data-listbox-value]')\n      if (hidden) {\n        var vals = []\n        el.querySelectorAll('[role=\"option\"][aria-selected=\"true\"]').forEach(function (o) {\n          vals.push(o.getAttribute('data-value') || (o.textContent || '').trim())\n        })\n        hidden.value = vals.join(',')\n      }\n      el.setAttribute('data-listbox-ready', 'true')\n    })(document.currentScript.parentNode.querySelector('[data-slot=\"listbox\"]'))\n  </script>\n</span>\n"
    }
  ]
}
