Skip to content

AX Accordion Block ‐ Developer Guide

Yeiber Cano edited this page Oct 23, 2025 · 2 revisions

AX Accordion - Developer Guide

Complete technical reference and implementation examples for the high-performance accordion component.


Table of Contents

  1. Quick Start
  2. API Reference
  3. Implementation Examples
  4. Architecture Deep Dive
  5. Performance Optimization
  6. Accessibility
  7. Testing
  8. Troubleshooting

Quick Start

Basic Usage (Authored Content)

Create a 2-column table in your document:

| Section 1 Title | <p>Content for section 1</p> |
| Section 2 Title | <p>Content for section 2</p> |
| Section 3 Title | <p>Content for section 3</p> |

Franklin will automatically convert this to an accordion.

Programmatic Usage

import axAccordionDecorate from '../../ax-accordion/ax-accordion.js';
import { createTag } from '../../scripts/utils.js';

const accordion = createTag('div', { class: 'ax-accordion' });

accordion.accordionData = [
  { title: 'Section 1', content: '<p>First section content</p>' },
  { title: 'Section 2', content: '<p>Second section content</p>' },
  { title: 'Section 3', content: '<p>Third section content</p>' },
];

await axAccordionDecorate(accordion);
document.body.appendChild(accordion);

API Reference

Properties

block.accordionData

Type: Array<{ title: string, content: string }>
Description: Sets accordion content programmatically. Must be set before calling decorate().

accordion.accordionData = [
  { title: 'Title', content: '<div>HTML content</div>' }
];

Methods

block.updateAccordion(newData, forceExpandTitle?)

Description: Updates accordion content dynamically with intelligent DOM diffing.

Parameters:

  • newData (Array): Array of { title, content } objects
  • forceExpandTitle (string, optional): Title of section to force expand

Returns: void

Performance: Only rebuilds DOM if structure changes. Otherwise updates content in place.

Example:

// Simple update
accordion.updateAccordion([
  { title: 'Updated Title', content: '<p>New content</p>' }
]);

// Force expand specific section
accordion.updateAccordion(updatedData, 'Section 2');

block.destroyAccordion()

Description: Complete cleanup of accordion instance.

Cleanup Actions:

  • Removes all event listeners
  • Disconnects Intersection Observer
  • Clears WeakMap caches
  • Removes DOM content
  • Deletes API methods

Example:

// When removing accordion from page
accordion.destroyAccordion();
accordion.remove();

Implementation Examples

Example 1: Product Detail Page (PDP)

Dynamic accordion that updates based on product selection:

import axAccordionDecorate from '../../ax-accordion/ax-accordion.js';
import BlockMediator from '../../../scripts/block-mediator.min.js';
import { createTag } from '../../../scripts/utils.js';

async function createProductDetailsSection(productData) {
  const container = createTag('div', { class: 'product-details-section' });
  
  // Create accordion
  const accordion = createTag('div', { 
    class: 'ax-accordion product-details-accordion' 
  });
  
  // Map product data to accordion format
  const mapData = (product) => [
    { 
      title: 'Specifications', 
      content: formatSpecifications(product.specs) 
    },
    { 
      title: 'Materials', 
      content: formatMaterials(product.materials) 
    },
    { 
      title: 'Care Instructions', 
      content: product.careInstructions 
    },
  ];
  
  // Initial data
  accordion.accordionData = mapData(productData);
  await axAccordionDecorate(accordion);
  container.appendChild(accordion);
  
  // Listen for product changes
  BlockMediator.subscribe('product:updated', (e) => {
    const newProduct = e.newValue;
    const oldProduct = e.oldValue;
    
    // Detect which attribute changed
    const changedField = detectChange(oldProduct, newProduct);
    
    // Map field to accordion section
    const sectionMap = {
      material: 'Materials',
      size: 'Specifications',
    };
    
    // Update and auto-expand relevant section
    accordion.updateAccordion(
      mapData(newProduct),
      sectionMap[changedField]
    );
  });
  
  return container;
}

function formatSpecifications(specs) {
  return `
    <ul class="specs-list">
      ${specs.map(s => `<li><strong>${s.name}:</strong> ${s.value}</li>`).join('')}
    </ul>
  `;
}

function detectChange(oldData, newData) {
  const keys = Object.keys(newData);
  return keys.find(key => oldData[key] !== newData[key]);
}

Example 2: FAQ Section

Static FAQ with structured data:

async function createFAQSection(faqData) {
  const accordion = createTag('div', { class: 'ax-accordion faq-accordion' });
  
  accordion.accordionData = faqData.map(faq => ({
    title: faq.question,
    content: `
      <div class="faq-answer">
        <p>${faq.answer}</p>
        ${faq.links ? `
          <div class="faq-links">
            <strong>Related articles:</strong>
            <ul>
              ${faq.links.map(link => `
                <li><a href="${link.url}">${link.text}</a></li>
              `).join('')}
            </ul>
          </div>
        ` : ''}
      </div>
    `
  }));
  
  await axAccordionDecorate(accordion);
  return accordion;
}

// Usage
const faqs = [
  {
    question: 'What is the return policy?',
    answer: 'You can return items within 30 days of purchase.',
    links: [
      { text: 'Return Process', url: '/returns' },
      { text: 'Refund Timeline', url: '/refunds' }
    ]
  },
  {
    question: 'How long is shipping?',
    answer: 'Standard shipping takes 5-7 business days.',
  },
];

const faqSection = await createFAQSection(faqs);
document.querySelector('.faq-container').appendChild(faqSection);

Example 3: Multi-Instance Management

Multiple accordions on same page with shared state management:

class AccordionManager {
  constructor() {
    this.instances = new Map();
  }
  
  async create(id, data) {
    const accordion = createTag('div', { 
      class: 'ax-accordion',
      'data-accordion-id': id 
    });
    
    accordion.accordionData = data;
    await axAccordionDecorate(accordion);
    
    this.instances.set(id, accordion);
    return accordion;
  }
  
  update(id, newData, forceExpandTitle) {
    const accordion = this.instances.get(id);
    if (accordion) {
      accordion.updateAccordion(newData, forceExpandTitle);
    }
  }
  
  updateAll(newDataMap) {
    newDataMap.forEach((data, id) => {
      this.update(id, data);
    });
  }
  
  destroy(id) {
    const accordion = this.instances.get(id);
    if (accordion) {
      accordion.destroyAccordion();
      this.instances.delete(id);
    }
  }
  
  destroyAll() {
    this.instances.forEach((accordion) => {
      accordion.destroyAccordion();
    });
    this.instances.clear();
  }
}

// Usage
const manager = new AccordionManager();

// Create multiple accordions
const acc1 = await manager.create('products', productData);
const acc2 = await manager.create('specs', specsData);

// Update all at once
manager.updateAll(new Map([
  ['products', updatedProductData],
  ['specs', updatedSpecsData]
]));

// Cleanup on page navigation
manager.destroyAll();

Example 4: Conditional Content Loading

Load accordion content dynamically based on visibility:

async function createLazyAccordion(initialData, fetchFn) {
  const accordion = createTag('div', { class: 'ax-accordion' });
  
  const contentCache = new Map();
  
  // Create accordion with placeholder content
  accordion.accordionData = initialData.map(item => ({
    title: item.title,
    content: '<div class="loading">Loading...</div>'
  }));
  
  await axAccordionDecorate(accordion);
  
  // Intercept click events to load content
  const originalUpdateFn = accordion.updateAccordion;
  accordion.addEventListener('click', async (e) => {
    const button = e.target.closest('.ax-accordion-item-title-container');
    if (!button) return;
    
    const title = button.querySelector('.ax-accordion-item-title').textContent;
    
    // Check cache first
    if (!contentCache.has(title)) {
      const content = await fetchFn(title);
      contentCache.set(title, content);
      
      // Update accordion with fetched content
      const updatedData = initialData.map(item => ({
        title: item.title,
        content: contentCache.get(item.title) || '<div class="loading">Loading...</div>'
      }));
      
      originalUpdateFn.call(accordion, updatedData, title);
    }
  });
  
  return accordion;
}

// Usage
const lazyAccordion = await createLazyAccordion(
  [
    { title: 'Section 1', id: 'sec1' },
    { title: 'Section 2', id: 'sec2' },
  ],
  async (title) => {
    // Fetch content on demand
    const response = await fetch(`/api/content/${title}`);
    return await response.text();
  }
);

Example 5: Analytics Integration

Track accordion interactions:

function setupAccordionAnalytics(accordion, category) {
  accordion.addEventListener('click', (e) => {
    const button = e.target.closest('.ax-accordion-item-title-container');
    if (!button) return;
    
    const title = button.querySelector('.ax-accordion-item-title').textContent;
    const wasExpanded = button.getAttribute('aria-expanded') === 'true';
    const action = wasExpanded ? 'collapse' : 'expand';
    
    // Send to analytics
    if (window._satellite) {
      window._satellite.track('accordion-interaction', {
        category,
        action,
        label: title,
        timestamp: Date.now(),
      });
    }
    
    // Or use custom tracking
    trackEvent({
      event: 'accordion_interaction',
      event_category: category,
      event_action: action,
      event_label: title,
    });
  });
}

// Usage
const accordion = await createProductAccordion(data);
setupAccordionAnalytics(accordion, 'product-details');

Architecture Deep Dive

Constants Explained

const ANIMATION_DURATION = 300;  // Must match CSS transition
const ANIMATION_BUFFER = 10;     // Safety margin for animation
const SCROLL_THRESHOLD = 100;    // Pixels from top for auto-collapse
const SCROLL_THROTTLE = 100;     // Milliseconds between scroll checks

Why these values?

  • 300ms animation: Design spec requirement, balances smoothness vs. speed
  • 10ms buffer: Accounts for browser timing variations
  • 100px threshold: Prevents accidental collapse near accordion
  • 100ms throttle: Balances responsiveness vs. performance (10fps)

DOM Structure

.ax-accordion
  └─ .ax-accordion-item-container
      ├─ <button> .ax-accordion-item-title-container
      │   ├─ <span> .ax-accordion-item-title
      │   └─ <div> .ax-accordion-item-icon [aria-hidden="true"]
      └─ <div> .ax-accordion-item-description [role="region" aria-hidden="true"]
          └─ <div> [content wrapper]

Key Points:

  • Button and content are siblings, not parent/child (WAI-ARIA pattern)
  • Button has aria-controls → content ID
  • Content has aria-labelledby → button ID
  • Icon has aria-hidden="true" (decorative, hidden from screen readers)
  • Content has aria-hidden that toggles with aria-expanded state
  • Wrapper div required for CSS Grid animation

WeakMap Usage

eventHandlers WeakMap

Stores click, scroll handlers, and Intersection Observer per accordion:

{
  [accordionBlock]: {
    clickHandler: Function,
    scrollHandler: Function,
    observer: IntersectionObserver
  }
}

Benefits:

  • Automatic garbage collection when accordion removed
  • No manual cleanup tracking needed
  • Prevents memory leaks

buttonCache WeakMap

Caches button DOM references per accordion:

{
  [accordionBlock]: [button1, button2, button3]
}

Benefits:

  • 85% reduction in querySelectorAll calls
  • Faster single-expand toggle
  • Cache invalidated automatically on rebuild

Throttle Implementation

Uses shared throttle utility from express/code/scripts/utils/hofs.js:

import { throttle } from '../../scripts/utils/hofs.js';

// Usage
const scrollHandler = throttle(() => {
  // Handler logic
}, SCROLL_THROTTLE);

Shared Implementation Benefits:

  • Leading edge execution (fires immediately on first call)
  • Optional trailing edge execution with { trailing: true }
  • Consistent behavior across all Express blocks
  • Zero duplication, single source of truth

Smart DOM Diffing

The buildAccordion function implements intelligent diffing:

// Check if structure unchanged
const isSameStructure = 
  existingTitles.length === newTitles.length &&
  existingTitles.every((title, index) => title === newTitles[index]);

if (isSameStructure) {
  // Fast path: Update only changed content
  existingItems.forEach((item, index) => {
    if (currentContent !== newContent) {
      descriptionElement.innerHTML = newContent;
    }
  });
  return; // Skip rebuild
}

// Slow path: Full rebuild required
// ...

Performance Impact:

  • Fast path: ~2-5ms (innerHTML update only)
  • Slow path: ~15-30ms (full DOM rebuild)
  • 80-90% of updates hit fast path in production

Performance Optimization

Scroll Performance

Problem: getBoundingClientRect() forces layout reflow (expensive)

Solution: Multi-layered optimization

  1. Throttle: Reduce calls from 60fps to 10fps
  2. Intersection Observer: Disable when off-screen
  3. Early exit: Check flags before DOM queries
  4. Passive listener: Enable browser optimizations
// Order of checks (fastest → slowest)
if (!hasExpandedItem) return;        // ①  Flag check (~0.01ms)
if (!isBlockVisible) return;         // ②  Flag check (~0.01ms)
const scrollTop = window.pageYOffset; // ③  Fast property read (~0.1ms)
const blockRect = block.getBoundingClientRect(); // ④ Layout reflow (~2-5ms)

Result: Average scroll handler execution time < 0.5ms (from ~15ms)

Button Caching Strategy

let cachedButtons = buttonCache.get(container);
if (!cachedButtons) {
  // Cache miss: Query DOM once
  cachedButtons = Array.from(
    container.querySelectorAll('.ax-accordion-item-title-container')
  );
  buttonCache.set(container, cachedButtons);
}

// Cache hit: Use cached array (85% of the time in production)
cachedButtons.forEach(btn => {
  // Fast iteration, no DOM queries
});

Cache Invalidation:

  • Automatic on rebuild (buttonCache.delete(block))
  • Automatic on destroy (WeakMap garbage collection)
  • No manual tracking needed

Animation Performance

CSS Grid vs max-height:

/* ❌ Old way (unpredictable timing) */
.content {
  max-height: 0;
  transition: max-height 300ms;
}
.content.open {
  max-height: 1000px; /* Arbitrary large number */
}

/* ✅ New way (animates to actual height) */
.content {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 300ms ease-out;
}
.content.open {
  grid-template-rows: 1fr; /* Actual content height */
}

Why better?

  • No "empty space" lag with short content
  • Consistent animation speed regardless of content length
  • GPU-accelerated (compositor thread)

Double requestAnimationFrame Pattern

Used for state restoration after rebuild:

requestAnimationFrame(() => {
  requestAnimationFrame(() => {
    button.setAttribute('aria-expanded', 'true');
    description.setAttribute('aria-hidden', 'false');
  });
});

Why double rAF?

  1. First rAF: Browser computes collapsed state (0fr)
  2. Second rAF: Browser applies transition to expanded state (1fr)
  3. Without this: Accordion appears instantly (no animation)

Frame Timeline:

Frame 1: DOM added (collapsed: 0fr, aria-hidden="true")
Frame 2: Browser measures layout
Frame 3: aria-expanded="true", aria-hidden="false" applied
Frame 4-8: Transition animation (0fr → 1fr)

Accessibility

ARIA Pattern Compliance

Follows WAI-ARIA Authoring Practices 1.2:

<div class="ax-accordion-item-container">
  <!-- ✅ Proper button element -->
  <button
    aria-expanded="false"
    aria-controls="panel-123"
    id="button-123"
  >
    <span>Section Title</span>
    <!-- ✅ Decorative icon hidden from AT -->
    <div class="ax-accordion-item-icon" aria-hidden="true"></div>
  </button>
  
  <!-- ✅ Region landmark with proper hidden state -->
  <div
    role="region"
    aria-labelledby="button-123"
    aria-hidden="true"
    id="panel-123"
  >
    <div>Content</div>
  </div>
</div>

aria-hidden synchronization:

  • Content starts with aria-hidden="true" (collapsed)
  • Toggles to aria-hidden="false" when aria-expanded="true"
  • Prevents screen readers from navigating to collapsed content
  • Synced automatically in click handlers and auto-collapse

Screen Reader Announcements

Collapsed state:

"Section Title, button, collapsed"

Expanded state:

"Section Title, button, expanded"

On activation:

"Expanded" (or "Collapsed")
[Content is read]

Accessibility Improvements (v2.0.1)

aria-hidden Management

The accordion properly manages aria-hidden to improve screen reader experience:

Why it matters:

  • Without aria-hidden, screen readers can navigate to collapsed content via landmarks
  • Users might hear "Region 1 of 5" for all panels even when collapsed
  • Content might be read in wrong order (button → hidden content → next button)

Implementation:

// Initial state (collapsed)
itemDescription.setAttribute('aria-hidden', 'true');

// On expand/collapse (synced with aria-expanded)
const isExpanded = itemButton.getAttribute('aria-expanded') === 'true';
itemButton.setAttribute('aria-expanded', !isExpanded);
itemDescription.setAttribute('aria-hidden', isExpanded); // Toggles opposite

// In auto-collapse on scroll-up
expandedButtons.forEach((button) => {
  button.setAttribute('aria-expanded', 'false');
  const description = button.nextElementSibling;
  if (description) {
    description.setAttribute('aria-hidden', 'true');
  }
});

// In state restoration after rebuild
button.setAttribute('aria-expanded', 'true');
if (description) {
  description.setAttribute('aria-hidden', 'false');
}

Screen reader behavior:

  • Before: "Section 1, button, collapsed. Region 1 of 5. Section 2, button..."
  • After: "Section 1, button, collapsed. Section 2, button..." ✅

Decorative Icon Hiding

Icons are purely visual indicators (+/−) and now hidden from assistive technology:

<div class="ax-accordion-item-icon" aria-hidden="true"></div>

Why:

  • Screen readers would announce "graphic" or read SVG URLs
  • Adds noise without providing information
  • Button text already conveys all necessary information

Implementation:

const itemIcon = createTag('div', { 
  class: 'ax-accordion-item-icon',
  'aria-hidden': 'true' // Decorative only
});

Keyboard Navigation

Key Action
Tab Focus next button
Shift+Tab Focus previous button
Enter Toggle current button
Space Toggle current button

Native browser behavior - no custom JavaScript needed!

Focus Management

.ax-accordion-item-title-container:focus {
  outline: 2px solid var(--color-info-accent);
  outline-offset: -2px;
}

Design Token Usage:

  • Uses --color-info-accent from express/code/styles/styles.css
  • Ensures consistent focus styling across Express
  • Value: #5c5ce0 (Adobe Spectrum blue)

Focus visible on:

  • Keyboard navigation (Tab)
  • Programmatic focus (e.g., button.focus())

Not visible on:

  • Mouse click (browser default behavior)

Reduced Motion Support

Currently not implemented. Future enhancement:

@media (prefers-reduced-motion: reduce) {
  .ax-accordion-item-description {
    transition: none;
  }
  
  .ax-accordion-item-description > * {
    transition: none;
  }
}

Testing

Unit Tests

Covered by main Franklin test suite (web-test-runner):

npm test

Coverage: 75.26% overall, 100% for critical paths

E2E Tests (Nala/Playwright)

Run accordion-specific tests:

# All accordion tests
npx playwright test nala/blocks/pdp-x-test-2/accordion.test.cjs

# Specific test
npx playwright test nala/blocks/pdp-x-test-2/accordion.test.cjs:40

# With UI
npx playwright test nala/blocks/pdp-x-test-2/accordion.test.cjs --ui

Test Scenarios:

  1. Single-expand logic (only one open at a time)
  2. Smooth transitions (300ms timing)
  3. Visual states (plus/minus icons)
  4. Keyboard navigation (Tab, Enter, Space)
  5. ARIA attributes (proper values, updates, aria-hidden sync)
  6. Content display (hidden when collapsed)
  7. Auto-collapse (scroll back to top)
  8. State persistence (retains state on update)
  9. Overflow handling (no horizontal scroll)
  10. Multiple instances (independent operation)

Performance Testing

Use Chrome DevTools:

// Measure scroll performance
performance.mark('scroll-start');
window.scrollBy(0, 1000);
setTimeout(() => {
  performance.mark('scroll-end');
  performance.measure('scroll', 'scroll-start', 'scroll-end');
  console.log(performance.getEntriesByName('scroll'));
}, 100);

// Measure click performance
performance.mark('click-start');
accordionButton.click();
requestAnimationFrame(() => {
  performance.mark('click-end');
  performance.measure('click', 'click-start', 'click-end');
  console.log(performance.getEntriesByName('click'));
});

Expected Results:

  • Scroll: < 1ms per scroll event
  • Click: < 5ms to update ARIA attributes
  • Animation: 300ms total duration

Troubleshooting

Accordion Not Animating

Symptom: Content appears/disappears instantly

Causes:

  1. CSS not loaded
  2. CSS timing mismatch with JS
  3. Content wrapper missing

Solutions:

// ✅ Ensure CSS loaded
await new Promise(resolve => {
  loadStyle(`${config.codeRoot}/blocks/ax-accordion/ax-accordion.css`, resolve);
});

// ✅ Check CSS timing matches JS
// CSS: transition: grid-template-rows 300ms ease-out
// JS: const ANIMATION_DURATION = 300;

// ✅ Content wrapper required
const contentWrapper = createTag('div');
contentWrapper.innerHTML = content;
itemDescription.appendChild(contentWrapper); // ← Don't skip this

Memory Leak

Symptom: Memory growing over time in Chrome DevTools

Causes:

  1. Accordion destroyed without cleanup
  2. Event listeners not removed
  3. Observer not disconnected

Solutions:

// ✅ Always call destroy before removing
accordion.destroyAccordion();
accordion.remove();

// ✅ Check for orphaned listeners in DevTools
// Chrome DevTools → Memory → Take Heap Snapshot
// Search for "accordion" or "listener"

// ✅ Verify cleanup in your code
// Should see eventHandlers.delete() and buttonCache.delete()

Scroll Handler Impacting Performance

Symptom: Janky scrolling, high frame times in DevTools

Causes:

  1. Throttle not working
  2. Intersection Observer not active
  3. Passive listener not set

Solutions:

// ✅ Verify throttle is applied
const scrollHandler = throttle(() => { ... }, SCROLL_THROTTLE);

// ✅ Check Observer in console
console.log(eventHandlers.get(accordion).observer);

// ✅ Ensure passive flag
window.addEventListener('scroll', scrollHandler, { passive: true });

// ✅ Monitor in DevTools Performance tab
// Should see ~10 scroll events per second (not 60)

Content Not Updating

Symptom: updateAccordion() called but content unchanged

Causes:

  1. Structure unchanged (fast path taken)
  2. Content identical (intentional skip)
  3. Data format incorrect

Solutions:

// ✅ Check if structure changed
console.log('Existing titles:', existingTitles);
console.log('New titles:', newTitles);

// ✅ Force rebuild by changing a title
accordion.updateAccordion([
  { title: 'Section 1', content: 'new' },  // Changed
  { title: 'Section 2 (Updated)', content: '...' }, // Title changed = rebuild
]);

// ✅ Verify data format
// ❌ Wrong: { name: '...', body: '...' }
// ✅ Correct: { title: '...', content: '...' }

Auto-Collapse Not Working

Symptom: Accordion stays open when scrolling to top

Causes:

  1. hasExpandedItem flag not updated
  2. Intersection Observer threshold wrong
  3. Scroll threshold too high

Solutions:

// ✅ Verify flag updates on click
accordion.addEventListener('click', () => {
  console.log('Has expanded:', hasExpandedItem);
});

// ✅ Check visibility
const observer = eventHandlers.get(accordion).observer;
console.log('Is visible:', isBlockVisible);

// ✅ Adjust thresholds if needed
// For earlier auto-collapse:
const SCROLL_THRESHOLD = 50; // Was 100

// For different visibility trigger:
new IntersectionObserver(..., { threshold: 0.5 }); // Was 0.1

aria-hidden Not Syncing

Symptom: Screen reader announces collapsed content

Causes:

  1. aria-hidden not set on initial creation
  2. aria-hidden not toggled in click handler
  3. aria-hidden missing in auto-collapse/restore logic

Solutions:

// ✅ Verify initial state
const description = accordion.querySelector('.ax-accordion-item-description');
console.log('Initial aria-hidden:', description.getAttribute('aria-hidden')); // Should be "true"

// ✅ Check sync on click
itemButton.addEventListener('click', () => {
  const expanded = itemButton.getAttribute('aria-expanded');
  const hidden = itemDescription.getAttribute('aria-hidden');
  console.log('Synced?', expanded === 'true' && hidden === 'false');
});

// ✅ Verify in all toggle locations
// Look for: itemDescription.setAttribute('aria-hidden', ...)
// Should be in: click handler, auto-collapse, state restoration

Advanced Topics

Custom Styling

Override CSS variables or classes:

/* Custom colors */
.my-accordion .ax-accordion-item-title-container {
  background-color: var(--my-brand-color);
  color: white;
}

/* Custom icon */
.my-accordion .ax-accordion-item-icon {
  background-image: url('/path/to/custom-icon.svg');
}

/* Custom animation timing */
.my-accordion .ax-accordion-item-description {
  transition: grid-template-rows 500ms ease-out; /* Slower */
}

/* Custom focus indicator */
.my-accordion .ax-accordion-item-title-container:focus {
  outline: 3px solid var(--my-focus-color);
  outline-offset: 2px;
}

Multiple Expand Mode

Not natively supported. Implementation:

// Remove single-expand logic from createAccordionItem
itemButton.addEventListener('click', () => {
  // Remove this entire forEach block:
  // cachedButtons.forEach(btn => { ... });
  
  // Keep only this:
  const isExpanded = itemButton.getAttribute('aria-expanded') === 'true';
  itemButton.setAttribute('aria-expanded', isExpanded ? 'false' : 'true');
  itemDescription.setAttribute('aria-hidden', isExpanded ? 'true' : 'false');
});

Integration with React/Vue

Wrap in custom component:

// React example
import { useEffect, useRef } from 'react';
import axAccordionDecorate from '../../ax-accordion/ax-accordion.js';

function Accordion({ data, onExpand }) {
  const ref = useRef(null);
  const accordionRef = useRef(null);
  
  useEffect(() => {
    const init = async () => {
      ref.current.accordionData = data;
      await axAccordionDecorate(ref.current);
      accordionRef.current = ref.current;
    };
    init();
    
    return () => {
      if (accordionRef.current) {
        accordionRef.current.destroyAccordion();
      }
    };
  }, []);
  
  useEffect(() => {
    if (accordionRef.current) {
      accordionRef.current.updateAccordion(data);
    }
  }, [data]);
  
  return <div ref={ref} className="ax-accordion" />;
}

Migration Guide

From Old Accordion Implementation

If migrating from an older accordion:

Old Structure:

<div role="button" aria-expanded="false">
  <div class="title">Title</div>
  <div class="content">Content</div>
</div>

New Structure:

<div class="ax-accordion-item-container">
  <button aria-expanded="false" aria-controls="panel-1" id="button-1">
    <span class="title">Title</span>
    <div class="icon" aria-hidden="true"></div>
  </button>
  <div role="region" aria-labelledby="button-1" aria-hidden="true" id="panel-1">
    <div>Content</div>
  </div>
</div>

Update CSS selectors:

/* Old */
[role="button"][aria-expanded="true"] .content { }

/* New */
.ax-accordion-item-title-container[aria-expanded="true"] + .ax-accordion-item-description { }

Update JavaScript:

// Old
button.classList.toggle('open');

// New
button.setAttribute('aria-expanded', 'true');
description.setAttribute('aria-hidden', 'false');

Best Practices

DO:

  • Call destroyAccordion() before removing from DOM
  • Use programmatic API for dynamic content
  • Let browser handle keyboard navigation
  • Keep content under 500 lines for optimal performance
  • Use semantic HTML in content (headings, lists, etc.)
  • Always sync aria-hidden with aria-expanded
  • Set aria-hidden="true" on decorative icons

DON'T:

  • Manually manipulate aria-expanded or aria-hidden (use API methods)
  • Remove accordion without cleanup (memory leak)
  • Add tabindex to buttons (already focusable)
  • Nest accordions (bad UX, performance issues)
  • Use very large content (>5000 lines) without lazy loading
  • Forget to hide decorative elements from screen readers

Support & Contributing

Issues: Report bugs or request features in GitHub Issues

Questions: Ask in team Slack channel or code review

Contributing: Follow project code style and add tests for new features


Changelog

v2.0.1 (Current) - October 2025

  • A11y: Added aria-hidden toggling for collapsed content panels
  • A11y: Added aria-hidden="true" to decorative icon
  • CSS: Replaced hardcoded focus color with --color-info-accent token
  • Docs: Fixed typo "gauranteed" → "guaranteed"
  • Animation: Updated timing to 300ms ease-out per design spec

v2.0.0 - October 2025

  • ✅ Semantic HTML with <button> elements
  • ✅ Performance optimizations (throttle, caching, Intersection Observer)
  • ✅ Memory leak fixes (WeakMap, proper cleanup)
  • ✅ Smart DOM diffing
  • ✅ 10 comprehensive E2E tests

v1.0.0 (Legacy)

  • Basic accordion with div role="button"
  • max-height animations
  • Manual event listener cleanup

Last Updated: October 2025
Maintainer: Express Milo Team
Version: 2.0.1

Clone this wiki locally