export class CollapsiblePanelAnimator { private static nextPanelId = 0; private _isOpen = false; private focusBeforeOpen: HTMLElement | null = null; public onOpen: () => unknown = () => {}; public onClose: () => unknown = () => {}; public constructor( private readonly toggleButton: HTMLButtonElement, private readonly collapsibleContent: HTMLElement, ignoreForCloseOnClick: HTMLElement ) { const panelId = collapsibleContent.id || `collapsible-panel-${CollapsiblePanelAnimator.nextPanelId++}`; collapsibleContent.id = panelId; toggleButton.setAttribute('aria-controls', panelId); if (!collapsibleContent.hasAttribute('role')) { collapsibleContent.setAttribute('role', 'region'); } if (!collapsibleContent.hasAttribute('aria-label')) { const label = toggleButton.getAttribute('aria-label') || toggleButton.textContent?.trim(); collapsibleContent.setAttribute('aria-label', `${label || 'Panel'} panel`); } if (!collapsibleContent.hasAttribute('tabindex')) { collapsibleContent.tabIndex = -1; } toggleButton.addEventListener('click', this.toggle.bind(this)); window.addEventListener( 'click', (event) => !ignoreForCloseOnClick.contains(event.target as Node) && this.close() ); window.addEventListener('keydown', (event) => { if (this._isOpen && event.key === 'Escape') { event.preventDefault(); this.close(); } }); this.syncAccessibility(); } public open() { if (this._isOpen) { return; } this.focusBeforeOpen = document.activeElement instanceof HTMLElement ? document.activeElement : null; this._isOpen = true; this.syncAccessibility(); this.onOpen(); this.focusPanel(); } public close() { if (!this._isOpen) { return; } const focusWasInside = this.collapsibleContent.contains(document.activeElement); this._isOpen = false; this.syncAccessibility(); this.onClose(); if (focusWasInside) { (this.focusBeforeOpen ?? this.toggleButton).focus({ preventScroll: true }); } } public toggle() { if (this._isOpen) { this.close(); } else { this.open(); } } public get isOpen() { return this._isOpen; } private syncAccessibility() { this.collapsibleContent.classList.toggle('hidden', !this._isOpen); this.toggleButton.classList.toggle('active', this._isOpen); this.toggleButton.setAttribute('aria-expanded', String(this._isOpen)); this.collapsibleContent.setAttribute('aria-hidden', String(!this._isOpen)); this.collapsibleContent.inert = !this._isOpen; } private focusPanel() { requestAnimationFrame(() => { if (this._isOpen) { this.collapsibleContent.focus({ preventScroll: true }); } }); } }