priv/static/pgflow_dashboard/hooks/mobile_menu.ts

/**
 * MobileMenu Hook
 *
 * Manages mobile navigation menu with body scroll locking and accessibility features.
 * Uses CSS classes controlled by Phoenix.LiveView.JS for animations.
 *
 * Usage in LiveView template:
 *   <div id="mobile-menu" phx-hook="MobileMenu" class="...">...</div>
 *
 * Expected DOM structure:
 *   - Element with id="mobile-menu-backdrop" for the overlay
 *   - Element with id="mobile-menu-panel" for the slide-out panel
 *   - Close button(s) with data-close-menu attribute
 */

interface HookContext {
  el: HTMLElement;
  pushEvent: (event: string, payload: object) => void;
  handleEvent: (event: string, callback: (payload: object) => void) => void;
}

interface MobileMenuHook extends HookContext {
  panel: HTMLElement | null;
  backdrop: HTMLElement | null;
  focusableElements: HTMLElement[];
  previousActiveElement: Element | null;
  isOpen: boolean;
  boundHandleKeydown: (e: KeyboardEvent) => void;
  boundHandleResize: () => void;

  mounted(): void;
  destroyed(): void;
  open(): void;
  close(): void;
  handleKeydown(e: KeyboardEvent): void;
  handleResize(): void;
  trapFocus(e: KeyboardEvent): void;
  getFocusableElements(): HTMLElement[];
  lockBodyScroll(): void;
  unlockBodyScroll(): void;
}

export const MobileMenu = {
  mounted(this: MobileMenuHook) {
    this.panel = document.getElementById("mobile-menu-panel");
    this.backdrop = document.getElementById("mobile-menu-backdrop");
    this.focusableElements = [];
    this.previousActiveElement = null;
    this.isOpen = false;

    // Bind event handlers
    this.boundHandleKeydown = this.handleKeydown.bind(this);
    this.boundHandleResize = this.handleResize.bind(this);

    // Listen for custom events from LiveView.JS
    this.el.addEventListener("menu:opened", () => this.open());
    this.el.addEventListener("menu:closed", () => this.close());

    // Close on resize to larger viewport
    window.addEventListener("resize", this.boundHandleResize);
  },

  destroyed(this: MobileMenuHook) {
    window.removeEventListener("resize", this.boundHandleResize);
    document.removeEventListener("keydown", this.boundHandleKeydown);
    this.unlockBodyScroll();
  },

  open(this: MobileMenuHook) {
    if (this.isOpen) return;
    this.isOpen = true;

    // Store current focus
    this.previousActiveElement = document.activeElement;

    // Lock body scroll
    this.lockBodyScroll();

    // Get focusable elements for focus trap
    this.focusableElements = this.getFocusableElements();

    // Focus first element
    if (this.focusableElements.length > 0) {
      setTimeout(() => {
        this.focusableElements[0]?.focus();
      }, 100);
    }

    // Add keyboard listener for escape and focus trap
    document.addEventListener("keydown", this.boundHandleKeydown);
  },

  close(this: MobileMenuHook) {
    if (!this.isOpen) return;
    this.isOpen = false;

    // Unlock body scroll
    this.unlockBodyScroll();

    // Remove keyboard listener
    document.removeEventListener("keydown", this.boundHandleKeydown);

    // Restore focus
    if (this.previousActiveElement instanceof HTMLElement) {
      this.previousActiveElement.focus();
    }
  },

  handleKeydown(this: MobileMenuHook, e: KeyboardEvent) {
    if (e.key === "Escape") {
      // Trigger close via JS command on backdrop click
      this.backdrop?.click();
      return;
    }

    if (e.key === "Tab") {
      this.trapFocus(e);
    }
  },

  handleResize(this: MobileMenuHook) {
    // Close menu if viewport becomes larger than sm breakpoint (640px)
    if (window.innerWidth >= 640 && this.isOpen) {
      this.backdrop?.click();
    }
  },

  trapFocus(this: MobileMenuHook, e: KeyboardEvent) {
    const firstElement = this.focusableElements[0];
    const lastElement = this.focusableElements[this.focusableElements.length - 1];

    if (e.shiftKey) {
      // Shift + Tab
      if (document.activeElement === firstElement) {
        e.preventDefault();
        lastElement?.focus();
      }
    } else {
      // Tab
      if (document.activeElement === lastElement) {
        e.preventDefault();
        firstElement?.focus();
      }
    }
  },

  getFocusableElements(this: MobileMenuHook): HTMLElement[] {
    if (!this.panel) return [];

    const selector = [
      'a[href]',
      'button:not([disabled])',
      'input:not([disabled])',
      'select:not([disabled])',
      'textarea:not([disabled])',
      '[tabindex]:not([tabindex="-1"])',
    ].join(', ');

    return Array.from(this.panel.querySelectorAll<HTMLElement>(selector));
  },

  lockBodyScroll(this: MobileMenuHook) {
    // Save current scroll position
    const scrollY = window.scrollY;
    document.body.style.position = 'fixed';
    document.body.style.top = `-${scrollY}px`;
    document.body.style.left = '0';
    document.body.style.right = '0';
    document.body.style.overflow = 'hidden';
  },

  unlockBodyScroll(this: MobileMenuHook) {
    const scrollY = document.body.style.top;
    document.body.style.position = '';
    document.body.style.top = '';
    document.body.style.left = '';
    document.body.style.right = '';
    document.body.style.overflow = '';
    window.scrollTo(0, parseInt(scrollY || '0') * -1);
  },
} as unknown as MobileMenuHook;