diff --git a/assets/js/app.js b/assets/js/app.js index 883ca30..73e57fc 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -102,3 +102,170 @@ liveSocket.connect() // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket +// Sidebar accessibility improvements +document.addEventListener("DOMContentLoaded", () => { + const drawerToggle = document.getElementById("main-drawer") + const sidebarToggle = document.getElementById("sidebar-toggle") + const sidebar = document.getElementById("main-sidebar") + + if (!drawerToggle || !sidebarToggle || !sidebar) return + + // Manage tabindex for sidebar elements based on open/closed state + const updateSidebarTabIndex = (isOpen) => { + // Find all potentially focusable elements (including those with tabindex="-1") + const allFocusableElements = sidebar.querySelectorAll( + 'a[href], button, select, input:not([type="hidden"]), [tabindex]' + ) + + allFocusableElements.forEach(el => { + // Skip the overlay button + if (el.closest('.drawer-overlay')) return + + if (isOpen) { + // Remove tabindex="-1" to make focusable when open + if (el.hasAttribute('tabindex') && el.getAttribute('tabindex') === '-1') { + el.removeAttribute('tabindex') + } + } else { + // Set tabindex="-1" to remove from tab order when closed + if (!el.hasAttribute('tabindex')) { + el.setAttribute('tabindex', '-1') + } else if (el.getAttribute('tabindex') !== '-1') { + // Store original tabindex in data attribute before setting to -1 + if (!el.hasAttribute('data-original-tabindex')) { + el.setAttribute('data-original-tabindex', el.getAttribute('tabindex')) + } + el.setAttribute('tabindex', '-1') + } + } + }) + } + + // Find first focusable element in sidebar + // Priority: first navigation link (menuitem) > other links > other focusable elements + const getFirstFocusableElement = () => { + // First, try to find the first navigation link (menuitem) + const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]:not([tabindex="-1"])') + if (firstNavLink && !firstNavLink.closest('.drawer-overlay')) { + return firstNavLink + } + + // Fallback: any navigation link + const firstLink = sidebar.querySelector('a[href]:not([tabindex="-1"])') + if (firstLink && !firstLink.closest('.drawer-overlay')) { + return firstLink + } + + // Last resort: any other focusable element + const focusableSelectors = [ + 'button:not([tabindex="-1"]):not([disabled])', + 'select:not([tabindex="-1"]):not([disabled])', + 'input:not([tabindex="-1"]):not([disabled]):not([type="hidden"])', + '[tabindex]:not([tabindex="-1"])' + ] + + for (const selector of focusableSelectors) { + const element = sidebar.querySelector(selector) + if (element && !element.closest('.drawer-overlay')) { + return element + } + } + return null + } + + // Update aria-expanded when drawer state changes + const updateAriaExpanded = () => { + const isOpen = drawerToggle.checked + sidebarToggle.setAttribute("aria-expanded", isOpen.toString()) + + // Update dropdown aria-expanded if present + const userMenuButton = sidebar.querySelector('button[aria-haspopup="true"]') + if (userMenuButton) { + const dropdown = userMenuButton.closest('.dropdown') + const isDropdownOpen = dropdown?.classList.contains('dropdown-open') + if (userMenuButton) { + userMenuButton.setAttribute("aria-expanded", (isDropdownOpen || false).toString()) + } + } + } + + // Listen for changes to the drawer checkbox + drawerToggle.addEventListener("change", () => { + const isOpen = drawerToggle.checked + updateAriaExpanded() + updateSidebarTabIndex(isOpen) + if (!isOpen) { + // When closing, return focus to toggle button + sidebarToggle.focus() + } + }) + + // Update on initial load + updateAriaExpanded() + updateSidebarTabIndex(drawerToggle.checked) + + // Close sidebar with ESC key + document.addEventListener("keydown", (e) => { + if (e.key === "Escape" && drawerToggle.checked) { + drawerToggle.checked = false + updateAriaExpanded() + updateSidebarTabIndex(false) + // Return focus to toggle button + sidebarToggle.focus() + } + }) + + // Improve keyboard navigation for sidebar toggle + sidebarToggle.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + const wasOpen = drawerToggle.checked + drawerToggle.checked = !drawerToggle.checked + updateAriaExpanded() + + // If opening, move focus to first element in sidebar + if (!wasOpen && drawerToggle.checked) { + updateSidebarTabIndex(true) + // Use setTimeout to ensure DOM is updated + setTimeout(() => { + const firstElement = getFirstFocusableElement() + if (firstElement) { + firstElement.focus() + } + }, 50) + } else if (wasOpen && !drawerToggle.checked) { + updateSidebarTabIndex(false) + } + } + }) + + // Also handle click events to update tabindex and focus + sidebarToggle.addEventListener("click", () => { + setTimeout(() => { + const isOpen = drawerToggle.checked + updateSidebarTabIndex(isOpen) + if (isOpen) { + const firstElement = getFirstFocusableElement() + if (firstElement) { + firstElement.focus() + } + } + }, 50) + }) + + // Handle dropdown keyboard navigation + const userMenuButton = sidebar?.querySelector('button[aria-haspopup="true"]') + if (userMenuButton) { + userMenuButton.addEventListener("click", () => { + setTimeout(updateAriaExpanded, 0) + }) + + userMenuButton.addEventListener("keydown", (e) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault() + userMenuButton.click() + } + }) + } +}) + diff --git a/lib/mv_web/components/layouts.ex b/lib/mv_web/components/layouts.ex index d45b8d5..00b0039 100644 --- a/lib/mv_web/components/layouts.ex +++ b/lib/mv_web/components/layouts.ex @@ -44,7 +44,7 @@ defmodule MvWeb.Layouts do assigns = assign(assigns, :club_name, club_name) ~H""" -