diff --git a/assets/css/app.css b/assets/css/app.css index ea63a2d..97961ab 100644 --- a/assets/css/app.css +++ b/assets/css/app.css @@ -99,4 +99,213 @@ /* Make LiveView wrapper divs transparent for layout */ [data-phx-session] { display: contents } +/* ============================================ + Sidebar Base Styles + ============================================ */ + +/* Desktop Sidebar Base */ +.sidebar { + @apply flex flex-col bg-base-200 min-h-screen; + @apply transition-[width] duration-300 ease-in-out; + @apply relative; + width: 16rem; /* Expanded: w-64 */ + z-index: 40; +} + +/* Collapsed State */ +[data-sidebar-expanded="false"] .sidebar { + width: 4rem; /* Collapsed: w-16 */ +} + +/* ============================================ + Header - Logo Centering + ============================================ */ + +/* Header container with smooth transition for gap */ +.sidebar > div:first-child { + @apply transition-all duration-300; +} + +/* ============================================ + Text Labels - Hide in Collapsed State + ============================================ */ + +.menu-label { + @apply transition-all duration-200 whitespace-nowrap; + transition-delay: 0ms; /* Expanded: sofort sichtbar */ +} + +[data-sidebar-expanded="false"] .sidebar .menu-label { + @apply opacity-0 w-0 overflow-hidden pointer-events-none; + transition-delay: 300ms; /* Warte bis Sidebar eingeklappt ist (300ms = duration der Sidebar width transition) */ +} + +/* ============================================ + Toggle Button Icon Swap + ============================================ */ + +.sidebar-collapsed-icon { + @apply hidden; +} + +[data-sidebar-expanded="false"] .sidebar .sidebar-expanded-icon { + @apply hidden; +} + +[data-sidebar-expanded="false"] .sidebar .sidebar-collapsed-icon { + @apply block; +} + +/* ============================================ + Menu Groups - Show/Hide Based on State + ============================================ */ + +.expanded-menu-group { + @apply block; +} + +.collapsed-menu-group { + @apply hidden; +} + +[data-sidebar-expanded="false"] .sidebar .expanded-menu-group { + @apply hidden; +} + +[data-sidebar-expanded="false"] .sidebar .collapsed-menu-group { + @apply block; +} + +/* Collapsed menu group button: center icon under logo */ +.sidebar .collapsed-menu-group button { + padding-left: 14px; +} + +/* ============================================ + Elements Only Visible in Expanded State + ============================================ */ + +.expanded-only { + @apply block transition-opacity duration-200; +} + +[data-sidebar-expanded="false"] .sidebar .expanded-only { + @apply hidden; +} + +/* ============================================ + Tooltip - Only Show in Collapsed State + ============================================ */ + +.sidebar .tooltip::before, +.sidebar .tooltip::after { + @apply opacity-0 pointer-events-none; +} + +[data-sidebar-expanded="false"] .sidebar .tooltip:hover::before, +[data-sidebar-expanded="false"] .sidebar .tooltip:hover::after { + @apply opacity-100; +} + +/* ============================================ + Menu Item Alignment - Icons Centered Under Logo + ============================================ */ + +/* Base alignment: Icons centered under logo (32px from left edge) + - Logo center: 16px padding + 16px (half of 32px) = 32px + - Icon center should be at 32px: 22px start + 10px (half of 20px) = 32px + - Menu has p-2 (8px), so links need 14px additional padding-left */ + +.sidebar .menu > li > a, +.sidebar .menu > li > button { + @apply transition-all duration-300; + padding-left: 14px; +} + +/* Collapsed state: same padding to keep icons at same position + - Remove gap so label (which is opacity-0 w-0) doesn't create space + - Keep padding-left at 14px so icons stay centered under logo */ +[data-sidebar-expanded="false"] .sidebar .menu > li > a, +[data-sidebar-expanded="false"] .sidebar .menu > li > button { + @apply gap-0; + padding-left: 14px; + padding-right: 14px; /* Center icon horizontally in 64px sidebar */ +} + +/* ============================================ + Footer Button Alignment - Left Aligned in Collapsed State + ============================================ */ + +[data-sidebar-expanded="false"] .sidebar .dropdown > button { + @apply px-0; + /* Buttons stay at left position, only label disappears */ +} + +/* ============================================ + User Menu Button - Focus Ring on Avatar + ============================================ */ + +/* Focus ring appears on the avatar when button is focused */ +.user-menu-button:focus .avatar > div { + @apply ring-2 ring-primary ring-offset-2 ring-offset-base-200; +} + +/* ============================================ + User Menu Button - Smooth Centering Transition + ============================================ */ + +/* User menu button transitions smoothly to center */ +.user-menu-button { + @apply transition-all duration-300; +} + +/* In collapsed state, center avatar under logo + - Avatar is 32px (w-8), center it in 64px sidebar + - (64px - 32px) / 2 = 16px padding → avatar center at 32px (same as logo center) */ +[data-sidebar-expanded="false"] .sidebar .user-menu-button { + @apply gap-0; + padding-left: 16px; + padding-right: 16px; + justify-content: center; +} + +/* ============================================ + User Menu Button - Hover Ring on Avatar + ============================================ */ + +/* Smooth transition for avatar ring effects */ +.user-menu-button .avatar > div { + @apply transition-all duration-200; +} + +/* Hover ring appears on the avatar when button is hovered */ +.user-menu-button:hover .avatar > div { + @apply ring-1 ring-neutral ring-offset-1 ring-offset-base-200; +} + +/* ============================================ + Mobile Drawer Width + ============================================ */ + +/* Auf Mobile (< 1024px) ist die Sidebar immer w-64 (16rem) wenn geöffnet */ +@media (max-width: 1023px) { + .drawer-side .sidebar { + width: 16rem; /* w-64 auch auf Mobile */ + } +} + +/* ============================================ + Drawer Side Overflow Fix für Desktop + ============================================ */ + +/* Im Desktop-Modus (lg:drawer-open) overflow auf visible setzen + damit Dropdowns und Tooltips über Main Content erscheinen können */ +@media (min-width: 1024px) { + .drawer.lg\:drawer-open .drawer-side { + overflow: visible !important; + overflow-x: visible !important; + overflow-y: visible !important; + } +} + /* This file is for your main application CSS */ diff --git a/assets/js/app.js b/assets/js/app.js index 883ca30..267ae05 100644 --- a/assets/js/app.js +++ b/assets/js/app.js @@ -73,6 +73,43 @@ Hooks.ComboBox = { } } +// SidebarState hook: Manages sidebar expanded/collapsed state +Hooks.SidebarState = { + mounted() { + // Restore state from localStorage + const expanded = localStorage.getItem('sidebar-expanded') !== 'false' + this.setSidebarState(expanded) + + // Expose toggle function globally + window.toggleSidebar = () => { + const current = this.el.dataset.sidebarExpanded === 'true' + this.setSidebarState(!current) + } + }, + + setSidebarState(expanded) { + // Convert boolean to string for consistency + const expandedStr = expanded ? 'true' : 'false' + + // Update data-attribute (CSS reacts to this) + this.el.dataset.sidebarExpanded = expandedStr + + // Persist to localStorage + localStorage.setItem('sidebar-expanded', expandedStr) + + // Update ARIA for accessibility + const toggleBtn = document.getElementById('sidebar-toggle') + if (toggleBtn) { + toggleBtn.setAttribute('aria-expanded', expandedStr) + } + }, + + destroyed() { + // Cleanup + delete window.toggleSidebar + } +} + let liveSocket = new LiveSocket("/live", Socket, { longPollFallbackMs: 2500, params: {_csrf_token: csrfToken}, @@ -102,3 +139,170 @@ liveSocket.connect() // >> liveSocket.disableLatencySim() window.liveSocket = liveSocket +// Sidebar accessibility improvements +document.addEventListener("DOMContentLoaded", () => { + const drawerToggle = document.getElementById("mobile-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/docker-compose.yml b/docker-compose.yml index 8621603..4c169b5 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,11 +10,7 @@ services: POSTGRES_PASSWORD: postgres POSTGRES_DB: mv_dev volumes: - - type: volume - source: postgres-data - target: /var/lib/postgresql/data - volume: - nocopy: true + - postgres-data:/var/lib/postgresql/data ports: - "5000:5432" networks: @@ -49,9 +45,7 @@ services: - rauthy-dev - local volumes: - - type: volume - source: rauthy-data - target: /app/data + - rauthy-data:/app/data volumes: postgres-data: diff --git a/docs/daisyui-drawer-pattern.md b/docs/daisyui-drawer-pattern.md new file mode 100644 index 0000000..dec599d --- /dev/null +++ b/docs/daisyui-drawer-pattern.md @@ -0,0 +1,533 @@ +# DaisyUI Drawer Pattern - Standard Implementation + +This document describes the standard DaisyUI drawer pattern for implementing responsive sidebars. It covers mobile overlay drawers, desktop persistent sidebars, and their combination. + +## Core Concept + +DaisyUI's drawer component uses a **checkbox-based toggle mechanism** combined with CSS to create accessible, responsive sidebars without custom JavaScript. + +### Key Components + +1. **`drawer`** - Container element +2. **`drawer-toggle`** - Hidden checkbox that controls open/close state +3. **`drawer-content`** - Main content area +4. **`drawer-side`** - Sidebar content (menu, navigation) +5. **`drawer-overlay`** - Optional overlay for mobile (closes drawer on click) + +## HTML Structure + +```html +
+ + + + +
+ + +
+ + +
+ + +
+
+``` + +## How drawer-toggle Works + +### Mechanism + +The `drawer-toggle` is a **hidden checkbox** that serves as the state controller: + +```html + +``` + +### Toggle Behavior + +1. **Label Connection**: Any `