// ============================================================================ // Stylescape | Drilldown Menu Manager // ============================================================================ // Manages a Foundation-style drilldown navigation menu with multi-level // support. Allows drilling down through nested menus with animated transitions. // Supports data-ss-drilldown attributes for declarative configuration. // ============================================================================ /** * Configuration options for DrilldownMenuManager */ export interface DrilldownMenuOptions { /** Animation duration in milliseconds */ animationDuration?: number; /** Auto-close submenus when clicking outside */ closeOnOutsideClick?: boolean; /** Show back button in submenus */ showBackButton?: boolean; /** Back button text */ backButtonText?: string; /** CSS class for the active level */ activeLevelClass?: string; /** Callback when navigating to a submenu */ onDrillDown?: (level: number, menu: HTMLElement) => void; /** Callback when navigating back */ onDrillUp?: (level: number, menu: HTMLElement) => void; /** Auto-calculate height based on content */ autoHeight?: boolean; } interface DrilldownLevel { element: HTMLElement; depth: number; parent: HTMLElement | null; } /** * Drilldown navigation menu with multi-level support. * * @example JavaScript * ```typescript * const drilldown = new DrilldownMenuManager(".drilldown", { * animationDuration: 300, * showBackButton: true, * backButtonText: "← Back" * }) * ``` * * @example HTML with data-ss * ```html * * ``` */ export class DrilldownMenuManager { private container: HTMLElement | null; private levels: DrilldownLevel[] = []; private currentDepth: number = 0; private history: HTMLElement[] = []; private options: Required; constructor( selectorOrElement: string | HTMLElement, options: DrilldownMenuOptions = {}, ) { this.container = typeof selectorOrElement === "string" ? document.querySelector(selectorOrElement) : selectorOrElement; this.options = { animationDuration: options.animationDuration ?? 300, closeOnOutsideClick: options.closeOnOutsideClick ?? true, showBackButton: options.showBackButton ?? true, backButtonText: options.backButtonText ?? "← Back", activeLevelClass: options.activeLevelClass ?? "drilldown__level--active", onDrillDown: options.onDrillDown ?? (() => {}), onDrillUp: options.onDrillUp ?? (() => {}), autoHeight: options.autoHeight ?? true, }; if (!this.container) { return; } this.init(); } // ======================================================================== // Initialization // ======================================================================== private init(): void { if (!this.container) return; // Find all menus and submenus this.discoverLevels(); // Add back buttons if enabled if (this.options.showBackButton) { this.addBackButtons(); } // Setup event listeners this.setupEventListeners(); // Set initial state this.showLevel(0); // Outside click handler if (this.options.closeOnOutsideClick) { document.addEventListener("click", this.handleOutsideClick); } } private discoverLevels(): void { if (!this.container) return; // Find main menu const mainMenu = this.container.querySelector( "[data-ss-drilldown-menu], .drilldown__menu", ); if (mainMenu) { this.levels.push({ element: mainMenu, depth: 0, parent: null, }); } // Find all submenus const submenus = this.container.querySelectorAll( "[data-ss-drilldown-submenu], .drilldown__submenu", ); submenus.forEach((submenu, _index) => { const parentItem = submenu.closest("li"); const parentMenu = parentItem?.closest("ul"); const parentLevel = this.levels.find( (l) => l.element === parentMenu, ); this.levels.push({ element: submenu, depth: (parentLevel?.depth ?? 0) + 1, parent: parentMenu || null, }); // Add trigger to parent item if (parentItem) { const trigger = parentItem.querySelector("a, button"); if (trigger) { trigger.setAttribute("data-ss-drilldown-trigger", ""); trigger.setAttribute( "data-ss-drilldown-target", String(this.levels.length - 1), ); } } }); } private addBackButtons(): void { this.levels.forEach((level) => { if ( level.depth > 0 && !level.element.querySelector("[data-ss-drilldown-back]") ) { const backItem = document.createElement("li"); backItem.setAttribute("data-ss-drilldown-back", ""); backItem.classList.add("drilldown__back"); const backButton = document.createElement("button"); backButton.type = "button"; backButton.textContent = this.options.backButtonText; backButton.classList.add("drilldown__back-button"); backItem.appendChild(backButton); level.element.insertBefore(backItem, level.element.firstChild); } }); } private setupEventListeners(): void { if (!this.container) return; // Forward navigation triggers this.container.addEventListener("click", (e) => { const trigger = (e.target as HTMLElement).closest( "[data-ss-drilldown-trigger]", ); if (trigger) { e.preventDefault(); const targetIndex = parseInt( trigger.dataset.ssDrilldownTarget || "0", 10, ); this.drillDown(targetIndex); } // Back button const backBtn = (e.target as HTMLElement).closest( "[data-ss-drilldown-back], .drilldown__back", ); if (backBtn) { e.preventDefault(); this.drillUp(); } }); // Keyboard navigation this.container.addEventListener("keydown", this.handleKeydown); } private handleKeydown = (e: KeyboardEvent): void => { if (e.key === "Escape" && this.currentDepth > 0) { this.drillUp(); } if (e.key === "ArrowLeft" && this.currentDepth > 0) { this.drillUp(); } if (e.key === "ArrowRight") { const focused = document.activeElement as HTMLElement; const trigger = focused?.closest( "[data-ss-drilldown-trigger]", ); if (trigger) { const targetIndex = parseInt( trigger.dataset.ssDrilldownTarget || "0", 10, ); this.drillDown(targetIndex); } } }; private handleOutsideClick = (e: MouseEvent): void => { if (this.container && !this.container.contains(e.target as Node)) { this.reset(); } }; // ======================================================================== // Public Methods // ======================================================================== /** * Navigate to a specific submenu level */ public drillDown(levelIndex: number): void { const level = this.levels[levelIndex]; if (!level) return; // Store current level in history const currentLevel = this.levels.find((l) => l.element.classList.contains(this.options.activeLevelClass), ); if (currentLevel) { this.history.push(currentLevel.element); } // Hide all levels this.levels.forEach((l) => { l.element.classList.remove(this.options.activeLevelClass); l.element.setAttribute("aria-hidden", "true"); }); // Show target level level.element.classList.add(this.options.activeLevelClass); level.element.setAttribute("aria-hidden", "false"); this.currentDepth = level.depth; // Update container height if (this.options.autoHeight && this.container) { this.container.style.height = `${level.element.scrollHeight}px`; } // Callback this.options.onDrillDown(level.depth, level.element); // Focus first item const firstFocusable = level.element.querySelector("a, button"); firstFocusable?.focus(); } /** * Navigate back to the previous level */ public drillUp(): void { if (this.history.length === 0) { this.showLevel(0); return; } const previousMenu = this.history.pop(); if (!previousMenu) return; // Hide current level this.levels.forEach((l) => { l.element.classList.remove(this.options.activeLevelClass); l.element.setAttribute("aria-hidden", "true"); }); // Show previous level previousMenu.classList.add(this.options.activeLevelClass); previousMenu.setAttribute("aria-hidden", "false"); const level = this.levels.find((l) => l.element === previousMenu); this.currentDepth = level?.depth ?? 0; // Update container height if (this.options.autoHeight && this.container) { this.container.style.height = `${previousMenu.scrollHeight}px`; } // Callback this.options.onDrillUp(this.currentDepth, previousMenu); } /** * Show a specific level by index */ public showLevel(index: number): void { if (index < 0 || index >= this.levels.length) return; this.levels.forEach((level, i) => { if (i === index) { level.element.classList.add(this.options.activeLevelClass); level.element.setAttribute("aria-hidden", "false"); } else { level.element.classList.remove(this.options.activeLevelClass); level.element.setAttribute("aria-hidden", "true"); } }); this.currentDepth = this.levels[index].depth; this.history = []; // Update container height if (this.options.autoHeight && this.container) { this.container.style.height = `${this.levels[index].element.scrollHeight}px`; } } /** * Reset to the root level */ public reset(): void { this.showLevel(0); } /** * Get the current depth level */ public getCurrentDepth(): number { return this.currentDepth; } /** * Destroy the drilldown menu */ public destroy(): void { if (this.container) { this.container.removeEventListener("keydown", this.handleKeydown); } document.removeEventListener("click", this.handleOutsideClick); this.levels = []; this.history = []; } // ======================================================================== // Static Factory // ======================================================================== /** * Initialize all drilldown menus with data-ss="drilldown" */ public static initDrilldowns(): DrilldownMenuManager[] { const managers: DrilldownMenuManager[] = []; const drilldowns = document.querySelectorAll( '[data-ss="drilldown"]', ); drilldowns.forEach((el) => { const duration = parseInt( el.dataset.ssDrilldownDuration || "300", 10, ); const backText = el.dataset.ssDrilldownBackText; const autoHeight = el.dataset.ssDrilldownAutoHeight !== "false"; managers.push( new DrilldownMenuManager(el, { animationDuration: duration, backButtonText: backText || "← Back", autoHeight, }), ); }); return managers; } } export default DrilldownMenuManager;