/** * USWDS Accordion Behavior * * Mirrors official USWDS accordion JavaScript behavior exactly * * @uswds-source https://github.com/uswds/uswds/blob/develop/packages/usa-accordion/src/index.js * @uswds-version 3.8.0 * @last-synced 2025-10-05 * @sync-status ✅ UP TO DATE * * IMPORTANT: This file replicates USWDS source code to maintain 100% behavior parity. * DO NOT add custom logic. ALL changes must come from USWDS source updates. */ // USWDS Constants (from usa-accordion/src/index.js) const PREFIX = 'usa'; const ACCORDION = `.${PREFIX}-accordion`; const BUTTON = `.${PREFIX}-accordion__button[aria-controls]`; const EXPANDED = 'aria-expanded'; const MULTISELECTABLE = 'data-allow-multiple'; const CONTROLS = 'aria-controls'; const HIDDEN = 'hidden'; /** * Toggle utility * * SOURCE: uswds-core/src/js/utils/toggle.js (Lines 1-28) * * @param button - The button element to toggle * @param expanded - Optional explicit state * @returns The resulting expanded state */ function toggle(button: HTMLElement, expanded?: boolean): boolean { // Null safety: if button is disconnected (e.g., during test cleanup), exit early if (!button || !button.isConnected) return false; let safeExpanded = expanded; if (typeof safeExpanded !== 'boolean') { safeExpanded = button.getAttribute(EXPANDED) === 'false'; } button.setAttribute(EXPANDED, String(safeExpanded)); const id = button.getAttribute(CONTROLS); const controls = id ? document.getElementById(id) : null; // Null safety: if controls element is missing (e.g., during test cleanup), exit early if (!controls) { return safeExpanded; } if (safeExpanded) { controls.removeAttribute(HIDDEN); // CRITICAL FIX: Force layout recalculation to fix Storybook navigation bug // Issue: After Storybook navigation, elements had BoundingClientRect with all zeros // despite having correct computed styles (display: block, proper height) // // Solution: Force immediate reflow - browser must recalculate layout // This is necessary because Storybook navigation doesn't fully reset layout state void controls.offsetHeight; // Force immediate reflow } else { controls.setAttribute(HIDDEN, ''); } return safeExpanded; } /** * Get accordion buttons * * SOURCE: usa-accordion/src/index.js (Lines 17-26) * * Get an Array of button elements belonging directly to the given accordion element. * * @param accordion - The accordion container element * @returns Array of button elements */ function getAccordionButtons(accordion: HTMLElement): HTMLElement[] { const buttons = Array.from(accordion.querySelectorAll(BUTTON)) as HTMLElement[]; return buttons.filter((button) => button.closest(ACCORDION) === accordion); } /** * Toggle accordion button * * SOURCE: usa-accordion/src/index.js (Lines 28-59) * * Toggle a button's "pressed" state, optionally providing a target state. * * @param button - The accordion button to toggle * @param expanded - Optional explicit expanded state * @returns The resulting expanded state */ export function toggleButton(button: HTMLElement, expanded?: boolean): boolean { const accordion = button.closest(ACCORDION) as HTMLElement; if (!accordion) { throw new Error(`${BUTTON} is missing outer ${ACCORDION}`); } const safeExpanded = toggle(button, expanded); // NOTE: multiselectable is opt-in to preserve legacy USWDS behavior // By default, expanding one item collapses others (single-select mode) const multiselectable = accordion.hasAttribute(MULTISELECTABLE); if (safeExpanded && !multiselectable) { getAccordionButtons(accordion).forEach((other) => { if (other !== button) { toggle(other, false); } }); } return safeExpanded; } /** * Show accordion button (expand) * * SOURCE: usa-accordion/src/index.js (Lines 61-66) * * @param button - The accordion button to expand * @returns true */ export function showButton(button: HTMLElement): boolean { return toggleButton(button, true); } /** * Hide accordion button (collapse) * * SOURCE: usa-accordion/src/index.js (Lines 68-73) * * @param button - The accordion button to collapse * @returns false */ export function hideButton(button: HTMLElement): boolean { return toggleButton(button, false); } /** * Check if element is in viewport * * SOURCE: uswds-core/src/js/utils/is-in-viewport.js * * @param el - Element to check * @returns Whether element is visible in viewport */ function isElementInViewport(el: HTMLElement): boolean { const rect = el.getBoundingClientRect(); return ( rect.top >= 0 && rect.left >= 0 && rect.bottom <= (window.innerHeight || document.documentElement.clientHeight) && rect.right <= (window.innerWidth || document.documentElement.clientWidth) ); } /** * Initialize accordion behavior * * SOURCE: usa-accordion/src/index.js (Lines 75-97) * * Sets up event delegation for accordion button clicks * * @param root - The accordion container or document root * @returns Cleanup function to remove event listeners */ export function initializeAccordion(root: HTMLElement | Document = document): () => void { // Handle click events (SOURCE: Lines 77-87) const handleClick = (event: Event) => { const button = (event.target as HTMLElement).closest(BUTTON) as HTMLElement; if (button) { toggleButton(button); if (button.getAttribute(EXPANDED) === 'true') { // We were just expanded, but if another accordion was also just // collapsed, we may no longer be in the viewport. This ensures // that we are still visible, so the user isn't confused. if (!isElementInViewport(button)) { button.scrollIntoView(); } } } }; // Initialize all buttons (SOURCE: Lines 89-93) const buttons = Array.from(root.querySelectorAll(BUTTON)) as HTMLElement[]; buttons.forEach((button) => { const expanded = button.getAttribute(EXPANDED) === 'true'; toggleButton(button, expanded); }); // Add click listener root.addEventListener('click', handleClick); // Return cleanup function return () => { root.removeEventListener('click', handleClick); }; } /** * Get all buttons for an accordion * * SOURCE: usa-accordion/src/index.js (export getButtons) * * @param accordion - The accordion container * @returns Array of accordion buttons */ export function getButtons(accordion: HTMLElement): HTMLElement[] { return getAccordionButtons(accordion); }