fleeting-garden/src/page/collapsible-panel-animator.ts
2026-05-13 21:07:10 +01:00

102 lines
2.8 KiB
TypeScript

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 });
}
});
}
}