{"name":"accordion","type":"registry:component","title":"Accordion","description":"A vertically stacked set of interactive headings that each reveal a section of content. Supports single and multiple open items with smooth animations and full keyboard navigation.","categories":["ui","accordion","web-component"],"author":"Lloyd Richards <lloyd.d.richards@gmail.com>","dependencies":["lucide-static"],"files":[{"path":"registry/ui/accordion/accordion.ts","type":"registry:ui","content":"import { html, LitElement, nothing, type PropertyValues } from \"lit\";\nimport { customElement, property, state } from \"lit/decorators.js\";\nimport { unsafeSVG } from \"lit/directives/unsafe-svg.js\";\nimport { ChevronDown } from \"lucide-static\";\nimport { TW } from \"@/registry/lib/tailwindMixin\";\nimport { cn } from \"@/registry/lib/utils\";\n\nconst TwLitElement = TW(LitElement);\n\n/**\n * Accordion component properties and events\n */\nexport interface AccordionProperties {\n  type?: \"single\" | \"multiple\";\n  value?: string;\n  defaultValue?: string;\n  collapsible?: boolean;\n  disabled?: boolean;\n}\n\nexport interface AccordionItemProperties {\n  value: string;\n  disabled?: boolean;\n}\n\nexport interface AccordionTriggerProperties {\n  disabled?: boolean;\n}\n\nexport interface AccordionContentProperties {\n  forceMount?: boolean;\n}\n\nexport interface AccordionValueChangeEvent extends CustomEvent {\n  detail: { value: string | string[] };\n}\n\n/**\n * Root accordion container managing state\n */\n@customElement(\"ui-accordion\")\nexport class Accordion extends TwLitElement implements AccordionProperties {\n  @property({ type: String }) type: \"single\" | \"multiple\" = \"single\";\n  @property({ type: String }) value = \"\";\n  @property({ type: String, attribute: \"default-value\" }) defaultValue = \"\";\n  @property({ type: Boolean }) collapsible = false;\n  @property({ type: Boolean }) disabled = false;\n\n  @state() _openValues: Set<string> = new Set();\n\n  override connectedCallback() {\n    super.connectedCallback();\n\n    // Initialize from defaultValue\n    if (!this.value && this.defaultValue) {\n      this.value = this.defaultValue;\n    }\n\n    this.addEventListener(\n      \"accordion-trigger-click\",\n      this.handleTriggerClick as EventListener,\n    );\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    this.removeEventListener(\n      \"accordion-trigger-click\",\n      this.handleTriggerClick as EventListener,\n    );\n  }\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (\n      changedProperties.has(\"value\") ||\n      changedProperties.has(\"_openValues\")\n    ) {\n      this.updateItems();\n      this.dispatchValueChangeEvent();\n    }\n  }\n\n  private handleTriggerClick = (e: CustomEvent) => {\n    if (this.disabled) return;\n\n    const clickedValue = e.detail.value;\n\n    if (this.type === \"single\") {\n      // Single mode: toggle or switch active item\n      if (this.collapsible && this.value === clickedValue) {\n        this.value = \"\";\n      } else {\n        this.value = clickedValue;\n      }\n    } else {\n      // Multiple mode: toggle in Set\n      const newValues = new Set(this._openValues);\n      if (newValues.has(clickedValue)) {\n        newValues.delete(clickedValue);\n      } else {\n        newValues.add(clickedValue);\n      }\n      this._openValues = newValues;\n    }\n  };\n\n  private dispatchValueChangeEvent() {\n    const value =\n      this.type === \"single\" ? this.value : Array.from(this._openValues);\n\n    this.dispatchEvent(\n      new CustomEvent(\"value-change\", {\n        detail: { value },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  }\n\n  private updateItems() {\n    const items = Array.from(\n      this.querySelectorAll(\"ui-accordion-item\"),\n    ) as AccordionItem[];\n\n    items.forEach((item) => {\n      if (this.type === \"single\") {\n        item._open = item.value === this.value;\n      } else {\n        item._open = this._openValues.has(item.value);\n      }\n    });\n  }\n\n  override render() {\n    return html`\n      <style>\n        :host {\n          display: block;\n        }\n      </style>\n      <slot></slot>\n    `;\n  }\n}\n\n/**\n * Individual accordion item\n */\n@customElement(\"ui-accordion-item\")\nexport class AccordionItem\n  extends TwLitElement\n  implements AccordionItemProperties\n{\n  @property({ type: String }) value = \"\";\n  @property({ type: Boolean }) disabled = false;\n\n  @state() _open = false;\n\n  override connectedCallback() {\n    super.connectedCallback();\n\n    // Sync with parent on mount\n    const accordion = this.closest(\"ui-accordion\") as Accordion | null;\n    if (accordion) {\n      this._open =\n        accordion.type === \"single\"\n          ? accordion.value === this.value\n          : accordion._openValues?.has(this.value) || false;\n    }\n  }\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (changedProperties.has(\"_open\")) {\n      this.updateChildren();\n      this.setAttribute(\"data-state\", this._open ? \"open\" : \"closed\");\n    }\n  }\n\n  private updateChildren() {\n    const trigger = this.querySelector(\n      \"ui-accordion-trigger\",\n    ) as AccordionTrigger | null;\n    const content = this.querySelector(\n      \"ui-accordion-content\",\n    ) as AccordionContent | null;\n\n    if (trigger) trigger._open = this._open;\n    if (content) content._open = this._open;\n  }\n\n  override render() {\n    return html`\n      <style>\n        :host {\n          display: block;\n        }\n      </style>\n      <div\n        class=${cn(\"border-b last:border-b-0\", this.className)}\n        data-state=${this._open ? \"open\" : \"closed\"}\n      >\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n/**\n * Accordion trigger button\n */\n@customElement(\"ui-accordion-trigger\")\nexport class AccordionTrigger\n  extends TwLitElement\n  implements AccordionTriggerProperties\n{\n  @property({ type: Boolean }) disabled = false;\n\n  @state() _open = false;\n\n  contentId = `accordion-content-${Math.random().toString(36).substring(2, 11)}`;\n\n  override connectedCallback() {\n    super.connectedCallback();\n\n    // Sync initial state\n    const item = this.closest(\"ui-accordion-item\") as AccordionItem | null;\n    if (item) {\n      this._open = item._open;\n    }\n  }\n\n  private handleClick = () => {\n    const accordion = this.closest(\"ui-accordion\") as Accordion | null;\n    const item = this.closest(\"ui-accordion-item\") as AccordionItem | null;\n\n    if (\n      !accordion ||\n      !item ||\n      this.disabled ||\n      item.disabled ||\n      accordion.disabled\n    ) {\n      return;\n    }\n\n    this.dispatchEvent(\n      new CustomEvent(\"accordion-trigger-click\", {\n        detail: { value: item.value },\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n\n  override render() {\n    const iconHtml = ChevronDown.replace(\n      \"<svg\",\n      '<svg class=\"text-muted-foreground pointer-events-none size-4 shrink-0 translate-y-0.5 transition-transform duration-200\"',\n    );\n\n    return html`\n      <style>\n        :host {\n          display: block;\n        }\n      </style>\n      <h3 class=\"flex\">\n        <button\n          type=\"button\"\n          aria-expanded=${this._open}\n          aria-controls=${this.contentId}\n          class=${cn(\n            \"flex flex-1 items-start justify-between gap-4 rounded-md py-4 text-left text-sm font-medium transition-all outline-none hover:underline\",\n            \"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]\",\n            \"disabled:pointer-events-none disabled:opacity-50\",\n            \"[&[data-state=open]>svg]:rotate-180\",\n            this.className,\n          )}\n          ?disabled=${this.disabled}\n          data-state=${this._open ? \"open\" : \"closed\"}\n          @click=${this.handleClick}\n        >\n          <slot></slot>\n          ${unsafeSVG(iconHtml)}\n        </button>\n      </h3>\n    `;\n  }\n}\n\n/**\n * Accordion content area\n */\n@customElement(\"ui-accordion-content\")\nexport class AccordionContent\n  extends TwLitElement\n  implements AccordionContentProperties\n{\n  @property({ type: Boolean }) forceMount = false;\n\n  @state() _open = false;\n  @state() private _animationState: \"closed\" | \"opening\" | \"open\" | \"closing\" =\n    \"closed\";\n  @state() private _hasRenderedOnce = false;\n\n  private _contentId =\n    `accordion-content-${Math.random().toString(36).substring(2, 11)}`;\n\n  override connectedCallback() {\n    super.connectedCallback();\n\n    // Sync initial state\n    const item = this.closest(\"ui-accordion-item\") as AccordionItem | null;\n    if (item) {\n      this._open = item._open;\n      // Set initial animation state without triggering animation\n      this._animationState = this._open ? \"open\" : \"closed\";\n    }\n  }\n\n  override firstUpdated() {\n    // Link to trigger (one-time setup)\n    const item = this.closest(\"ui-accordion-item\") as AccordionItem | null;\n    const trigger = item?.querySelector(\n      \"ui-accordion-trigger\",\n    ) as AccordionTrigger | null;\n    if (trigger) {\n      this.id = trigger.contentId;\n    }\n  }\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (changedProperties.has(\"_open\")) {\n      this.handleOpenStateChange();\n    }\n  }\n\n  private handleOpenStateChange() {\n    if (this._open) {\n      // Opening\n      if (!this._hasRenderedOnce) {\n        // First render: skip animation\n        this._animationState = \"open\";\n      } else {\n        this._animationState = \"opening\";\n      }\n    } else {\n      // Closing (only if currently open or opening)\n      if (\n        this._animationState === \"open\" ||\n        this._animationState === \"opening\"\n      ) {\n        this._animationState = \"closing\";\n      }\n    }\n\n    this.updateContentHeight();\n  }\n\n  private updateContentHeight() {\n    // Wait for render to complete\n    this.updateComplete.then(() => {\n      const regionDiv = this.shadowRoot?.querySelector(\"[role='region']\") as\n        | (HTMLElement & { _accordionAnimationHandler?: EventListener })\n        | null;\n\n      if (!regionDiv) return;\n\n      // Calculate content height for animation variable\n      const innerDiv = regionDiv.querySelector(\"div\") as HTMLElement | null;\n      if (innerDiv) {\n        const height = innerDiv.scrollHeight;\n        regionDiv.style.setProperty(\n          \"--accordion-content-height\",\n          `${height}px`,\n        );\n      }\n\n      // Clean up previous listener if it exists\n      if (regionDiv._accordionAnimationHandler) {\n        regionDiv.removeEventListener(\n          \"animationend\",\n          regionDiv._accordionAnimationHandler,\n        );\n      }\n\n      // Create new listener\n      const handleAnimationEnd = () => {\n        if (this._animationState === \"opening\") {\n          this._animationState = \"open\";\n        } else if (this._animationState === \"closing\") {\n          this._animationState = \"closed\";\n        }\n      };\n\n      regionDiv._accordionAnimationHandler = handleAnimationEnd;\n      regionDiv.addEventListener(\"animationend\", handleAnimationEnd, {\n        once: true, // Auto-remove after first fire\n      });\n    });\n  }\n\n  override render() {\n    // Mark that we've rendered at least once\n    this._hasRenderedOnce = true;\n\n    // Render if not fully closed (or if forceMount enabled)\n    if (this._animationState === \"closed\" && !this.forceMount) {\n      return nothing;\n    }\n\n    return html`\n      <style>\n        :host {\n          display: block;\n        }\n      </style>\n      <div\n        role=\"region\"\n        id=${this._contentId}\n        class=${cn(\n          \"overflow-hidden text-sm\",\n          this._animationState === \"opening\" && \"animate-accordion-down\",\n          this._animationState === \"closing\" && \"animate-accordion-up\",\n          this.className,\n        )}\n        data-state=${this._open ? \"open\" : \"closed\"}\n      >\n        <div class=\"pt-0 pb-4\">\n          <slot></slot>\n        </div>\n      </div>\n    `;\n  }\n}\n\n// Register components in global type map\ndeclare global {\n  interface HTMLElementTagNameMap {\n    \"ui-accordion\": Accordion;\n    \"ui-accordion-item\": AccordionItem;\n    \"ui-accordion-trigger\": AccordionTrigger;\n    \"ui-accordion-content\": AccordionContent;\n  }\n\n  interface HTMLElementEventMap {\n    \"value-change\": AccordionValueChangeEvent;\n    \"accordion-trigger-click\": CustomEvent<{ value: string }>;\n  }\n}\n"},{"path":"registry/ui/accordion/accordion.stories.ts","type":"registry:ui","content":"import \"./accordion\";\nimport type { Meta, StoryObj } from \"@storybook/web-components-vite\";\nimport { html } from \"lit\";\nimport type { AccordionProperties } from \"./accordion\";\n\ntype AccordionArgs = AccordionProperties;\n\n/**\n * A vertically stacked set of interactive headings that each reveal a section\n * of content.\n */\nconst meta: Meta<AccordionArgs> = {\n  title: \"ui/Accordion\",\n  component: \"ui-accordion\",\n  tags: [\"autodocs\"],\n  argTypes: {\n    type: {\n      control: \"radio\",\n      description: \"Type of accordion behavior\",\n      options: [\"single\", \"multiple\"],\n    },\n    collapsible: {\n      control: \"boolean\",\n      description: \"Can an open accordion be collapsed using the trigger\",\n    },\n    disabled: {\n      control: \"boolean\",\n    },\n    value: {\n      control: \"text\",\n      description: \"Controlled value (single mode only)\",\n    },\n    defaultValue: {\n      control: \"text\",\n      description: \"Default open item value\",\n    },\n  },\n  args: {\n    type: \"single\",\n    collapsible: true,\n    disabled: false,\n    defaultValue: \"\",\n  },\n  render: (args) => html`\n    <ui-accordion\n      .type=${args.type || \"single\"}\n      .value=${args.value || \"\"}\n      .defaultValue=${args.defaultValue || \"\"}\n      ?collapsible=${args.collapsible}\n      ?disabled=${args.disabled}\n      class=\"w-96\"\n    >\n      <ui-accordion-item value=\"item-1\">\n        <ui-accordion-trigger>Is it accessible?</ui-accordion-trigger>\n        <ui-accordion-content>\n          Yes. It adheres to the WAI-ARIA design pattern.\n        </ui-accordion-content>\n      </ui-accordion-item>\n      <ui-accordion-item value=\"item-2\">\n        <ui-accordion-trigger>Is it styled?</ui-accordion-trigger>\n        <ui-accordion-content>\n          Yes. It comes with default styles that matches the other components'\n          aesthetic.\n        </ui-accordion-content>\n      </ui-accordion-item>\n      <ui-accordion-item value=\"item-3\">\n        <ui-accordion-trigger>Is it animated?</ui-accordion-trigger>\n        <ui-accordion-content>\n          Yes. It's animated by default, but you can disable it if you prefer.\n        </ui-accordion-content>\n      </ui-accordion-item>\n    </ui-accordion>\n  `,\n  parameters: {\n    layout: \"centered\",\n  },\n};\n\nexport default meta;\n\ntype Story = StoryObj<AccordionArgs>;\n\n/**\n * The default behavior of the accordion allows only one item to be open.\n */\nexport const Default: Story = {};\n\n/**\n * Accordion with multiple items open at the same time.\n */\nexport const Multiple: Story = {\n  args: {\n    type: \"multiple\",\n  },\n};\n\n/**\n * Accordion with disabled state.\n */\nexport const Disabled: Story = {\n  args: {\n    disabled: true,\n  },\n};\n\n/**\n * Accordion with a default open item.\n */\nexport const WithDefaultValue: Story = {\n  args: {\n    defaultValue: \"item-2\",\n  },\n};\n\n/**\n * Accordion in single mode without collapsible behavior.\n */\nexport const NonCollapsible: Story = {\n  args: {\n    type: \"single\",\n    collapsible: false,\n    defaultValue: \"item-1\",\n  },\n};\n"}]}