{"name":"collapsible","type":"registry:component","title":"Collapsible","description":"An interactive component which expands/collapses a panel. Useful for showing and hiding content with smooth animations and accessibility features.","categories":["ui","collapsible","web-component"],"author":"Lloyd Richards <lloyd.d.richards@gmail.com>","dependencies":["lucide-static"],"files":[{"path":"registry/ui/collapsible/collapsible.ts","type":"registry:ui","content":"import { html, LitElement, nothing, type PropertyValues } from \"lit\";\nimport { customElement, property, state } from \"lit/decorators.js\";\nimport { TW } from \"@/registry/lib/tailwindMixin\";\nimport { cn } from \"@/registry/lib/utils\";\n\nconst TwLitElement = TW(LitElement);\n\n/**\n * Collapsible component properties and events\n */\nexport interface CollapsibleProperties {\n  open?: boolean;\n  defaultOpen?: boolean;\n  disabled?: boolean;\n}\n\nexport interface CollapsibleTriggerProperties {\n  disabled?: boolean;\n}\n\nexport interface CollapsibleContentProperties {\n  forceMount?: boolean;\n}\n\nexport interface CollapsibleOpenChangeEvent extends CustomEvent {\n  detail: { open: boolean };\n}\n\n/**\n * Root collapsible container managing state\n */\n@customElement(\"ui-collapsible\")\nexport class Collapsible extends TwLitElement implements CollapsibleProperties {\n  @property({ type: Boolean, reflect: true }) open = false;\n  @property({ type: Boolean, attribute: \"default-open\" }) defaultOpen = false;\n  @property({ type: Boolean }) disabled = false;\n\n  override connectedCallback() {\n    super.connectedCallback();\n\n    // Initialize from defaultOpen\n    if (this.defaultOpen && !this.hasAttribute(\"open\")) {\n      this.open = this.defaultOpen;\n    }\n\n    this.addEventListener(\n      \"collapsible-trigger-click\",\n      this.handleTriggerClick as EventListener,\n    );\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    this.removeEventListener(\n      \"collapsible-trigger-click\",\n      this.handleTriggerClick as EventListener,\n    );\n  }\n\n  override updated(changedProperties: PropertyValues) {\n    super.updated(changedProperties);\n\n    if (changedProperties.has(\"open\")) {\n      this.updateContent();\n\n      this.dispatchEvent(\n        new CustomEvent(\"open-change\", {\n          detail: { open: this.open },\n          bubbles: true,\n          composed: true,\n        }),\n      );\n    }\n  }\n\n  private handleTriggerClick = () => {\n    if (!this.disabled) {\n      this.open = !this.open;\n    }\n  };\n\n  private updateContent() {\n    const content = this.querySelector(\n      \"ui-collapsible-content\",\n    ) as CollapsibleContent | null;\n    if (content) {\n      content._open = this.open;\n    }\n  }\n\n  /**\n   * Public method to toggle open/closed state\n   */\n  public toggle() {\n    if (!this.disabled) {\n      this.open = !this.open;\n    }\n  }\n\n  /**\n   * Public method to open the collapsible\n   */\n  public show() {\n    if (!this.disabled) {\n      this.open = true;\n    }\n  }\n\n  /**\n   * Public method to close the collapsible\n   */\n  public hide() {\n    if (!this.disabled) {\n      this.open = false;\n    }\n  }\n\n  override render() {\n    return html`\n      <style>\n        :host {\n          display: block;\n        }\n      </style>\n      <div data-state=${this.open ? \"open\" : \"closed\"}>\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n/**\n * Collapsible trigger button (unstyled)\n */\n@customElement(\"ui-collapsible-trigger\")\nexport class CollapsibleTrigger\n  extends TwLitElement\n  implements CollapsibleTriggerProperties\n{\n  @property({ type: Boolean }) disabled = false;\n\n  private handleClick = () => {\n    const collapsible = this.closest(\"ui-collapsible\") as Collapsible | null;\n\n    if (!collapsible || this.disabled || collapsible.disabled) {\n      return;\n    }\n\n    this.dispatchEvent(\n      new CustomEvent(\"collapsible-trigger-click\", {\n        bubbles: true,\n        composed: true,\n      }),\n    );\n  };\n\n  override render() {\n    const collapsible = this.closest(\"ui-collapsible\") as Collapsible | null;\n    const isOpen = collapsible?.open || false;\n\n    return html`\n      <style>\n        :host {\n          display: block;\n        }\n      </style>\n      <div @click=${this.handleClick} data-state=${isOpen ? \"open\" : \"closed\"}>\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n/**\n * Collapsible content area\n */\n@customElement(\"ui-collapsible-content\")\nexport class CollapsibleContent\n  extends TwLitElement\n  implements CollapsibleContentProperties\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 observer?: MutationObserver;\n\n  override connectedCallback() {\n    super.connectedCallback();\n\n    // Sync with parent\n    const collapsible = this.closest(\"ui-collapsible\") as Collapsible | null;\n    if (collapsible) {\n      this._open = collapsible.open;\n      // Set initial animation state without triggering animation\n      this._animationState = this._open ? \"open\" : \"closed\";\n\n      // Observe parent for changes\n      this.observer = new MutationObserver(() => {\n        this._open = collapsible.open;\n      });\n      this.observer.observe(collapsible, {\n        attributes: true,\n        attributeFilter: [\"open\"],\n      });\n    }\n  }\n\n  override disconnectedCallback() {\n    super.disconnectedCallback();\n    this.observer?.disconnect();\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 & { _collapsibleAnimationHandler?: EventListener })\n        | null;\n\n      if (!regionDiv) return;\n\n      // Calculate content height for animation variable\n      const height = regionDiv.scrollHeight;\n      regionDiv.style.setProperty(\n        \"--collapsible-content-height\",\n        `${height}px`,\n      );\n\n      // Clean up previous listener if it exists\n      if (regionDiv._collapsibleAnimationHandler) {\n        regionDiv.removeEventListener(\n          \"animationend\",\n          regionDiv._collapsibleAnimationHandler,\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._collapsibleAnimationHandler = 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    const collapsible = this.closest(\"ui-collapsible\") as Collapsible | null;\n    const triggerId = collapsible\n      ?.querySelector(\"ui-collapsible-trigger\")\n      ?.getAttribute(\"id\");\n\n    return html`\n      <style>\n        :host {\n          display: block;\n        }\n      </style>\n      <div\n        role=\"region\"\n        aria-labelledby=${triggerId || nothing}\n        class=${cn(\n          \"overflow-hidden\",\n          this._animationState === \"opening\" && \"animate-collapsible-down\",\n          this._animationState === \"closing\" && \"animate-collapsible-up\",\n          this.className,\n        )}\n        data-state=${this._open ? \"open\" : \"closed\"}\n      >\n        <slot></slot>\n      </div>\n    `;\n  }\n}\n\n// Register components in global type map\ndeclare global {\n  interface HTMLElementTagNameMap {\n    \"ui-collapsible\": Collapsible;\n    \"ui-collapsible-trigger\": CollapsibleTrigger;\n    \"ui-collapsible-content\": CollapsibleContent;\n  }\n\n  interface HTMLElementEventMap {\n    \"open-change\": CollapsibleOpenChangeEvent;\n    \"collapsible-trigger-click\": CustomEvent<Record<string, never>>;\n  }\n}\n"},{"path":"registry/ui/collapsible/collapsible.stories.ts","type":"registry:ui","content":"import \"./collapsible\";\nimport type { Meta, StoryObj } from \"@storybook/web-components-vite\";\nimport { html } from \"lit\";\nimport { unsafeSVG } from \"lit/directives/unsafe-svg.js\";\nimport { Info } from \"lucide-static\";\nimport type { CollapsibleProperties } from \"./collapsible\";\n\ntype CollapsibleArgs = CollapsibleProperties;\n\n/**\n * An interactive component which expands/collapses a panel.\n */\nconst meta: Meta<CollapsibleArgs> = {\n  title: \"ui/Collapsible\",\n  component: \"ui-collapsible\",\n  tags: [\"autodocs\"],\n  argTypes: {\n    open: {\n      control: \"boolean\",\n      description: \"Controlled open state\",\n    },\n    defaultOpen: {\n      control: \"boolean\",\n      description: \"Default open state (uncontrolled)\",\n    },\n    disabled: {\n      control: \"boolean\",\n      description: \"Disable interaction\",\n    },\n  },\n  args: {\n    disabled: false,\n  },\n  render: (args) => html`\n    <ui-collapsible\n      ?open=${args.open}\n      ?defaultOpen=${args.defaultOpen}\n      ?disabled=${args.disabled}\n      class=\"w-96\"\n    >\n      <ui-collapsible-trigger class=\"flex gap-2\">\n        <h3 class=\"font-semibold\">Can I use this in my project?</h3>\n        ${unsafeSVG(Info.replace(\"<svg\", '<svg class=\"size-6\"'))}\n      </ui-collapsible-trigger>\n      <ui-collapsible-content>\n        <p class=\"pt-2\">\n          Yes. Free to use for personal and commercial projects. No attribution\n          required.\n        </p>\n      </ui-collapsible-content>\n    </ui-collapsible>\n  `,\n  parameters: {\n    layout: \"centered\",\n  },\n};\n\nexport default meta;\n\ntype Story = StoryObj<CollapsibleArgs>;\n\n/**\n * The default form of the collapsible.\n */\nexport const Default: Story = {};\n\n/**\n * Use the `disabled` prop to disable the interaction.\n */\nexport const Disabled: Story = {\n  args: {\n    disabled: true,\n  },\n};\n\n/**\n * Collapsible with default open state.\n */\nexport const DefaultOpen: Story = {\n  args: {\n    defaultOpen: true,\n  },\n};\n\n/**\n * Collapsible with controlled open state.\n */\nexport const Controlled: Story = {\n  args: {\n    open: true,\n  },\n};\n\n/**\n * Collapsible with custom trigger styling.\n */\nexport const WithCustomTrigger: Story = {\n  render: (args) => html`\n    <ui-collapsible\n      ?open=${args.open}\n      ?defaultOpen=${args.defaultOpen}\n      ?disabled=${args.disabled}\n      class=\"w-96\"\n    >\n      <ui-collapsible-trigger>\n        <button\n          class=\"flex w-full items-center justify-between rounded-md border border-input bg-background px-4 py-2 text-sm font-medium hover:bg-accent hover:text-accent-foreground\"\n        >\n          <span>Show more information</span>\n          <svg\n            class=\"size-4 transition-transform duration-200 data-[state=open]:rotate-180\"\n            xmlns=\"http://www.w3.org/2000/svg\"\n            width=\"24\"\n            height=\"24\"\n            viewBox=\"0 0 24 24\"\n            fill=\"none\"\n            stroke=\"currentColor\"\n            stroke-width=\"2\"\n            stroke-linecap=\"round\"\n            stroke-linejoin=\"round\"\n          >\n            <path d=\"m6 9 6 6 6-6\" />\n          </svg>\n        </button>\n      </ui-collapsible-trigger>\n      <ui-collapsible-content>\n        <div class=\"rounded-b-md border border-t-0 border-input p-4\">\n          <p class=\"text-sm text-muted-foreground\">\n            This is additional information that can be revealed by clicking the\n            trigger above. The content is hidden by default and smoothly\n            animates when toggled.\n          </p>\n        </div>\n      </ui-collapsible-content>\n    </ui-collapsible>\n  `,\n};\n"}]}