// ============================================================================
// Stylescape | Responsive Menu Manager
// ============================================================================
// Responsive navigation menu with mobile toggle and accessibility support.
// Supports data-ss-menu attributes for declarative configuration.
// ============================================================================
/**
* Configuration options for ResponsiveMenuManager
*/
export interface ResponsiveMenuOptions {
/** Breakpoint for mobile view in pixels */
breakpoint?: number;
/** CSS class for expanded state */
expandedClass?: string;
/** CSS class for mobile view */
mobileClass?: string;
/** Close menu on link click */
closeOnLinkClick?: boolean;
/** Close menu on outside click */
closeOnOutsideClick?: boolean;
/** Close menu on Escape key */
closeOnEscape?: boolean;
/** Animation duration in milliseconds */
animationDuration?: number;
/** Trap focus when menu is open */
trapFocus?: boolean;
/** Callback when menu opens */
onOpen?: () => void;
/** Callback when menu closes */
onClose?: () => void;
/** Selector for nav links */
linkSelector?: string;
}
/**
* Accessible responsive navigation menu.
*
* @example JavaScript
* ```typescript
* const menu = new ResponsiveMenuManager("#navMenu", "#menuToggle", {
* breakpoint: 768,
* closeOnLinkClick: true,
* closeOnOutsideClick: true
* })
* ```
*
* @example HTML with data-ss
* ```html
*
* ```
*/
export class ResponsiveMenuManager {
private menu: HTMLElement | null;
private toggle: HTMLElement | null;
private options: Required;
private isExpanded: boolean = false;
private isMobile: boolean = false;
private focusableElements: HTMLElement[] = [];
constructor(
menuSelector: string | HTMLElement,
toggleSelector: string | HTMLElement,
options: ResponsiveMenuOptions = {},
) {
this.menu =
typeof menuSelector === "string"
? document.querySelector(menuSelector)
: menuSelector;
this.toggle =
typeof toggleSelector === "string"
? document.querySelector(toggleSelector)
: toggleSelector;
this.options = {
breakpoint: options.breakpoint ?? 768,
expandedClass: options.expandedClass ?? "nav--expanded",
mobileClass: options.mobileClass ?? "nav--mobile",
closeOnLinkClick: options.closeOnLinkClick ?? true,
closeOnOutsideClick: options.closeOnOutsideClick ?? true,
closeOnEscape: options.closeOnEscape ?? true,
animationDuration: options.animationDuration ?? 300,
trapFocus: options.trapFocus ?? true,
onOpen: options.onOpen ?? (() => {}),
onClose: options.onClose ?? (() => {}),
linkSelector: options.linkSelector ?? "a",
};
if (!this.menu) {
console.warn(
"[Stylescape] ResponsiveMenuManager menu element not found",
);
return;
}
if (!this.toggle) {
console.warn(
"[Stylescape] ResponsiveMenuManager toggle element not found",
);
return;
}
this.init();
}
// ========================================================================
// Public Properties
// ========================================================================
/**
* Check if menu is expanded
*/
public get expanded(): boolean {
return this.isExpanded;
}
/**
* Check if in mobile view
*/
public get mobile(): boolean {
return this.isMobile;
}
// ========================================================================
// Public Methods
// ========================================================================
/**
* Open the menu
*/
public open(): void {
if (!this.menu || this.isExpanded) return;
this.isExpanded = true;
this.menu.hidden = false;
this.menu.classList.add(this.options.expandedClass);
this.toggle?.setAttribute("aria-expanded", "true");
// Prevent body scroll on mobile
if (this.isMobile) {
document.body.style.overflow = "hidden";
}
// Focus first link
if (this.options.trapFocus) {
this.updateFocusableElements();
requestAnimationFrame(() => {
this.focusableElements[0]?.focus();
});
}
this.options.onOpen();
}
/**
* Close the menu
*/
public close(): void {
if (!this.menu || !this.isExpanded) return;
this.isExpanded = false;
this.menu.classList.remove(this.options.expandedClass);
this.toggle?.setAttribute("aria-expanded", "false");
// Allow body scroll
document.body.style.overflow = "";
// Hide after animation (for mobile)
if (this.isMobile) {
setTimeout(() => {
if (!this.isExpanded && this.menu) {
this.menu.hidden = true;
}
}, this.options.animationDuration);
}
// Return focus to toggle
this.toggle?.focus();
this.options.onClose();
}
/**
* Toggle the menu
*/
public toggleMenu(): void {
if (this.isExpanded) {
this.close();
} else {
this.open();
}
}
/**
* Force check of window size
*/
public checkWindowSize(): void {
const wasMobile = this.isMobile;
this.isMobile = window.innerWidth < this.options.breakpoint;
const nav = this.menu?.closest("nav") || this.menu?.parentElement;
if (this.isMobile) {
nav?.classList.add(this.options.mobileClass);
if (!this.isExpanded && this.menu) {
this.menu.hidden = true;
}
} else {
nav?.classList.remove(this.options.mobileClass);
if (this.menu) {
this.menu.hidden = false;
}
// Close expanded menu when resizing to desktop
if (wasMobile && this.isExpanded) {
this.close();
}
}
}
/**
* Destroy the manager
*/
public destroy(): void {
this.toggle?.removeEventListener("click", this.handleToggleClick);
document.removeEventListener("click", this.handleOutsideClick);
document.removeEventListener("keydown", this.handleKeydown);
window.removeEventListener("resize", this.handleResize);
this.menu
?.querySelectorAll(this.options.linkSelector)
.forEach((link) => {
link.removeEventListener("click", this.handleLinkClick);
});
this.menu = null;
this.toggle = null;
}
// ========================================================================
// Static Factory
// ========================================================================
/**
* Initialize responsive menus with data-ss="responsive-menu"
*/
public static initMenus(): ResponsiveMenuManager[] {
const managers: ResponsiveMenuManager[] = [];
document
.querySelectorAll('[data-ss="responsive-menu"]')
.forEach((nav) => {
const toggle = nav.querySelector(
"[data-ss-menu-toggle]",
);
const menu = nav.querySelector(
"[data-ss-menu-content]",
);
if (toggle && menu) {
const breakpoint = nav.dataset.ssMenuBreakpoint;
managers.push(
new ResponsiveMenuManager(menu, toggle, {
breakpoint: breakpoint
? parseInt(breakpoint, 10)
: undefined,
}),
);
}
});
return managers;
}
// ========================================================================
// Private Methods
// ========================================================================
private init(): void {
if (!this.menu || !this.toggle) return;
// Setup ARIA
const menuId = this.menu.id || `nav-menu-${Date.now()}`;
this.menu.id = menuId;
this.toggle.setAttribute("aria-controls", menuId);
this.toggle.setAttribute("aria-expanded", "false");
this.menu.setAttribute("role", "navigation");
// Initial check
this.checkWindowSize();
// Add event listeners
this.toggle.addEventListener("click", this.handleToggleClick);
window.addEventListener("resize", this.handleResize);
if (this.options.closeOnOutsideClick) {
document.addEventListener("click", this.handleOutsideClick);
}
if (this.options.closeOnEscape) {
document.addEventListener("keydown", this.handleKeydown);
}
if (this.options.closeOnLinkClick) {
this.menu
.querySelectorAll(this.options.linkSelector)
.forEach((link) => {
link.addEventListener("click", this.handleLinkClick);
});
}
}
private updateFocusableElements(): void {
if (!this.menu) return;
const focusableSelectors = [
"button:not([disabled])",
"a[href]",
"input:not([disabled])",
'[tabindex]:not([tabindex="-1"])',
].join(",");
this.focusableElements = Array.from(
this.menu.querySelectorAll(focusableSelectors),
);
}
// ========================================================================
// Event Handlers
// ========================================================================
private handleToggleClick = (event: Event): void => {
event.preventDefault();
this.toggleMenu();
};
private handleOutsideClick = (event: Event): void => {
if (!this.isExpanded || !this.isMobile) return;
const target = event.target as Node;
const nav = this.menu?.closest("nav") || this.menu?.parentElement;
if (nav && !nav.contains(target)) {
this.close();
}
};
private handleKeydown = (event: KeyboardEvent): void => {
if (!this.isExpanded) return;
if (event.key === "Escape") {
event.preventDefault();
this.close();
return;
}
// Focus trap
if (event.key === "Tab" && this.options.trapFocus && this.isMobile) {
this.handleTabKey(event);
}
};
private handleTabKey(event: KeyboardEvent): void {
if (this.focusableElements.length === 0) return;
const firstElement = this.focusableElements[0];
const lastElement =
this.focusableElements[this.focusableElements.length - 1];
if (event.shiftKey) {
if (document.activeElement === firstElement) {
event.preventDefault();
lastElement.focus();
}
} else {
if (document.activeElement === lastElement) {
event.preventDefault();
firstElement.focus();
}
}
}
private handleLinkClick = (): void => {
if (this.isMobile && this.isExpanded) {
this.close();
}
};
private handleResize = (): void => {
this.checkWindowSize();
};
}
export default ResponsiveMenuManager;