Accordion

A flexible component for organizing content into collapsible sections. It handles state management automatically via injected JavaScript and uses StateManager for unique ID generation.

Available in PHPXUI

This component is part of the PHPXUI library. You can easily install it and explore more pre-built components for your project.

Implementation

Create a new file at src/app/Lib/Components/Accordion.php.

Lifecycle Note

In Prisma PHPX, execution starts from the innermost child to the outermost parent. The Accordion (parent) constructs last, which is why we inject the JavaScript there to ensure all child IDs are ready.

<?php

namespace Lib\PHPX\Components;

use Lib\MainLayout;
use Lib\PHPX\PHPX;
use Lib\StateManager;

class Accordion extends PHPX
{
    public ?string $class = '';
    public mixed $children = null;

    public function __construct(array $props = [])
    {
        parent::__construct($props);

        // Inject JS only once per page load via the Parent component
        $accordionScript = <<<HTML
        <script>
            function toggleAccordion(id) {
                const content = document.getElementById('content-' + id);
                const icon = document.getElementById('icon-' + id);
                const button = document.querySelector('[aria-controls="content-' + id + '"]');
                
                // Toggle current state
                const isHidden = content.classList.contains('hidden');
                
                // Optional: Close others (Accordion behavior vs Collapsible)
                // To make this a true accordion (one open at a time), uncomment below:
                /*
                document.querySelectorAll('[id^="content-"]').forEach(el => el.classList.add('hidden'));
                document.querySelectorAll('[id^="icon-"]').forEach(el => el.classList.remove('rotate-180'));
                document.querySelectorAll('[aria-expanded]').forEach(el => el.setAttribute('aria-expanded', 'false'));
                */

                if (isHidden) {
                    content.classList.remove('hidden');
                    icon.classList.add('rotate-180');
                    button.setAttribute('aria-expanded', 'true');
                } else {
                    content.classList.add('hidden');
                    icon.classList.remove('rotate-180');
                    button.setAttribute('aria-expanded', 'false');
                }
            }
        </script>
        HTML;

        MainLayout::addFooterScript($accordionScript);
    }

    public function render(): string
    {
        $class = $this->getMergeClasses('w-full', $this->class);
        $attributes = $this->getAttributes([
            'class' => $class,
        ]);

        return <<<HTML
        <div {$attributes}>{$this->children}</div>
        HTML;
    }
}

class AccordionItem extends PHPX
{
    public ?string $class = '';
    public mixed $children = null;

    public function render(): string
    {
        $class = $this->getMergeClasses('border-b border-border', $this->class);
        $attributes = $this->getAttributes([
            'class' => $class,
        ]);

        return <<<HTML
        <div {$attributes}>{$this->children}</div>
        HTML;
    }
}

class AccordionTrigger extends PHPX
{
    public ?string $class = '';
    public mixed $children = null;

    public function __construct(array $props = [])
    {
        parent::__construct($props);
        // Generate a unique ID for this trigger/content pair
        StateManager::setState('accordionItemId', uniqid());
    }

    public function render(): string
    {
        $accordionItemId = StateManager::getState('accordionItemId');

        $class = $this->getMergeClasses(
            'flex flex-1 items-center justify-between py-4 font-medium transition-all hover:underline text-foreground', 
            $this->props['class'] ?? ''
        );
        $attributes = $this->getAttributes([
            'aria-expanded' => 'false',
            'aria-controls' => 'content-' . $accordionItemId,
            'id' => $accordionItemId,
            'type' => 'button',
            'class' => $class,
            'onclick' => "toggleAccordion('$accordionItemId')",
        ]);

        return <<<HTML
        <button {$attributes}>
            {$this->children}
            <svg id="icon-$accordionItemId" class="h-4 w-4 shrink-0 transition-transform duration-200 text-muted-foreground" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor">
                <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
            </svg>
        </button>
        HTML;
    }
}

class AccordionContent extends PHPX
{
    public ?string $class = '';
    public mixed $children = null;

    public function render(): string
    {
        $accordionItemId = StateManager::getState('accordionItemId');
        $class = $this->getMergeClasses(
            'hidden overflow-hidden pb-4 pt-0 text-sm text-muted-foreground',
            $this->props['class'] ?? ''
        );
        $attributes = $this->getAttributes([
            'aria-labelledby' => $accordionItemId,
            'id' => 'content-' . $accordionItemId,
            'role' => 'region',
            'class' => $class,
        ]);

        return <<<HTML
        <div {$attributes}>   
            {$this->children}
        </div>
        HTML;
    }
}

Sub-components

Component Role
AccordionItem Wraps a single trigger/content pair. Adds the bottom border.
AccordionTrigger The clickable button. Handles ID generation and toggle logic.
AccordionContent The hidden section. Inherits the ID from the Trigger via StateManager.

Usage Example

<?php

  use Lib\PHPX\Components\{Accordion, AccordionItem, AccordionTrigger, AccordionContent};
  
  ?>
  
  <div class="w-full max-w-lg mx-auto p-10">
      <h3 class="text-xl font-bold mb-4">FAQ</h3>
      
      <Accordion>
      
          <AccordionItem>
              <AccordionTrigger>Is it accessible?</AccordionTrigger>
              <AccordionContent>
                  Yes. It uses WAI-ARIA attributes like aria-expanded and aria-controls to ensure screen reader support.
              </AccordionContent>
          </AccordionItem>
          
          <AccordionItem>
              <AccordionTrigger>Can I style it with Tailwind?</AccordionTrigger>
              <AccordionContent>
                  Absolutely. Pass a <code class="bg-muted px-1 rounded">class</code> prop to any sub-component to override default styles.
              </AccordionContent>
          </AccordionItem>
          
          <AccordionItem>
              <AccordionTrigger>Is it animated?</AccordionTrigger>
              <AccordionContent>
                  Yes, the chevron icon rotates 180 degrees automatically using the CSS class <code class="bg-muted px-1 rounded">rotate-180</code>.
              </AccordionContent>
          </AccordionItem>
          
      </Accordion>
  </div>