-
Notifications
You must be signed in to change notification settings - Fork 18
AX Accordion Block ‐ Developer Guide
Complete technical reference and implementation examples for the high-performance accordion component.
- Quick Start
- API Reference
- Implementation Examples
- Architecture Deep Dive
- Performance Optimization
- Accessibility
- Testing
- Troubleshooting
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.
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);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>' }
];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');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();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]);
}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);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();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();
}
);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');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 checksWhy 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)
.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-hiddenthat toggles witharia-expandedstate - Wrapper div required for CSS Grid animation
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
Caches button DOM references per accordion:
{
[accordionBlock]: [button1, button2, button3]
}Benefits:
- 85% reduction in
querySelectorAllcalls - Faster single-expand toggle
- Cache invalidated automatically on rebuild
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
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
Problem: getBoundingClientRect() forces layout reflow (expensive)
Solution: Multi-layered optimization
- Throttle: Reduce calls from 60fps to 10fps
- Intersection Observer: Disable when off-screen
- Early exit: Check flags before DOM queries
- 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)
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
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)
Used for state restoration after rebuild:
requestAnimationFrame(() => {
requestAnimationFrame(() => {
button.setAttribute('aria-expanded', 'true');
description.setAttribute('aria-hidden', 'false');
});
});Why double rAF?
- First rAF: Browser computes collapsed state (0fr)
- Second rAF: Browser applies transition to expanded state (1fr)
- 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)
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"whenaria-expanded="true" - Prevents screen readers from navigating to collapsed content
- Synced automatically in click handlers and auto-collapse
Collapsed state:
"Section Title, button, collapsed"
Expanded state:
"Section Title, button, expanded"
On activation:
"Expanded" (or "Collapsed")
[Content is read]
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..." ✅
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
});| 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!
.ax-accordion-item-title-container:focus {
outline: 2px solid var(--color-info-accent);
outline-offset: -2px;
}Design Token Usage:
- Uses
--color-info-accentfromexpress/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)
Currently not implemented. Future enhancement:
@media (prefers-reduced-motion: reduce) {
.ax-accordion-item-description {
transition: none;
}
.ax-accordion-item-description > * {
transition: none;
}
}Covered by main Franklin test suite (web-test-runner):
npm testCoverage: 75.26% overall, 100% for critical paths
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 --uiTest Scenarios:
- Single-expand logic (only one open at a time)
- Smooth transitions (300ms timing)
- Visual states (plus/minus icons)
- Keyboard navigation (Tab, Enter, Space)
- ARIA attributes (proper values, updates, aria-hidden sync)
- Content display (hidden when collapsed)
- Auto-collapse (scroll back to top)
- State persistence (retains state on update)
- Overflow handling (no horizontal scroll)
- Multiple instances (independent operation)
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
Symptom: Content appears/disappears instantly
Causes:
- CSS not loaded
- CSS timing mismatch with JS
- 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 thisSymptom: Memory growing over time in Chrome DevTools
Causes:
- Accordion destroyed without cleanup
- Event listeners not removed
- 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()Symptom: Janky scrolling, high frame times in DevTools
Causes:
- Throttle not working
- Intersection Observer not active
- 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)Symptom: updateAccordion() called but content unchanged
Causes:
- Structure unchanged (fast path taken)
- Content identical (intentional skip)
- 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: '...' }Symptom: Accordion stays open when scrolling to top
Causes:
- hasExpandedItem flag not updated
- Intersection Observer threshold wrong
- 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.1Symptom: Screen reader announces collapsed content
Causes:
- aria-hidden not set on initial creation
- aria-hidden not toggled in click handler
- 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 restorationOverride 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;
}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');
});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" />;
}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');✅ 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-hiddenwitharia-expanded - Set
aria-hidden="true"on decorative icons
❌ DON'T:
- Manually manipulate
aria-expandedoraria-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
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
- ✅ A11y: Added
aria-hiddentoggling for collapsed content panels - ✅ A11y: Added
aria-hidden="true"to decorative icon - ✅ CSS: Replaced hardcoded focus color with
--color-info-accenttoken - ✅ Docs: Fixed typo "gauranteed" → "guaranteed"
- ✅ Animation: Updated timing to 300ms ease-out per design spec
- ✅ Semantic HTML with
<button>elements - ✅ Performance optimizations (throttle, caching, Intersection Observer)
- ✅ Memory leak fixes (WeakMap, proper cleanup)
- ✅ Smart DOM diffing
- ✅ 10 comprehensive E2E tests
- 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