feat: implement standard-compliant sidebar with comprehensive tests
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
Implement a new sidebar component based on DaisyUI Drawer pattern without custom CSS variants. The sidebar supports desktop (expanded/collapsed states) and mobile (overlay drawer) with full accessibility compliance. Sidebar Implementation: - Refactor sidebar component with sidebar_header, menu_item, menu_group, sidebar_footer sub-components - Add logo (mila.svg) with size-8 (32px) always visible - Implement toggle button with icon swap (chevron-left/right) for desktop - Add nested menu support with details/summary (expanded) and dropdown (collapsed) patterns - Implement footer with language selector (expanded-only), theme toggle, and user menu with avatar - Update layouts.ex to use drawer pattern with data-sidebar-expanded attribute for state management CSS & JavaScript: - Add CSS styles for sidebar state management via data-attribute selectors - Implement SidebarState JavaScript hook for localStorage persistence - Add smooth width transitions (w-64 ↔ w-16) for desktop collapsed state - Add CSS classes for expanded-only, menu-label, and icon visibility Documentation: - Add sidebar-analysis-current-state.md: Analysis of current implementation - Add sidebar-requirements-v2.md: Complete specification for new sidebar - Add daisyui-drawer-pattern.md: DaisyUI pattern documentation - Add umsetzung-sidebar.md: Step-by-step implementation guide Testing: - Add comprehensive component tests for all sidebar sub-components - Add integration tests for sidebar state management and mobile drawer - Extend accessibility tests (ARIA labels, roles, keyboard navigation) - Add regression tests for duplicate IDs, hover effects, and tooltips - Ensure full test coverage per specification requirements
This commit is contained in:
parent
b0097ab99d
commit
16ca4efc03
10 changed files with 5439 additions and 194 deletions
|
|
@ -99,4 +99,138 @@
|
|||
/* 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 */
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Text Labels - Hide in Collapsed State
|
||||
============================================ */
|
||||
|
||||
.menu-label {
|
||||
@apply transition-all duration-200 whitespace-nowrap;
|
||||
}
|
||||
|
||||
[data-sidebar-expanded="false"] .sidebar .menu-label {
|
||||
@apply opacity-0 w-0 overflow-hidden pointer-events-none;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
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;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
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 - Center in Collapsed State
|
||||
============================================ */
|
||||
|
||||
[data-sidebar-expanded="false"] .sidebar .menu > li > a,
|
||||
[data-sidebar-expanded="false"] .sidebar .menu > li > button {
|
||||
@apply justify-center px-0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
Footer Button Alignment - Center in Collapsed State
|
||||
============================================ */
|
||||
|
||||
[data-sidebar-expanded="false"] .sidebar .dropdown > button {
|
||||
@apply justify-center px-0;
|
||||
}
|
||||
|
||||
/* ============================================
|
||||
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 */
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
@ -104,7 +141,7 @@ window.liveSocket = liveSocket
|
|||
|
||||
// Sidebar accessibility improvements
|
||||
document.addEventListener("DOMContentLoaded", () => {
|
||||
const drawerToggle = document.getElementById("main-drawer")
|
||||
const drawerToggle = document.getElementById("mobile-drawer")
|
||||
const sidebarToggle = document.getElementById("sidebar-toggle")
|
||||
const sidebar = document.getElementById("main-sidebar")
|
||||
|
||||
|
|
|
|||
532
docs/daisyui-drawer-pattern.md
Normal file
532
docs/daisyui-drawer-pattern.md
Normal file
|
|
@ -0,0 +1,532 @@
|
|||
# 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
|
||||
<div class="drawer">
|
||||
<!-- Hidden checkbox controls the drawer state -->
|
||||
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<!-- Main content area -->
|
||||
<div class="drawer-content">
|
||||
<!-- Page content goes here -->
|
||||
<label for="my-drawer" class="btn btn-primary">Open drawer</label>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar content -->
|
||||
<div class="drawer-side">
|
||||
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
<ul class="menu p-4 w-80 min-h-full bg-base-200 text-base-content">
|
||||
<!-- Sidebar content goes here -->
|
||||
<li><a>Sidebar Item 1</a></li>
|
||||
<li><a>Sidebar Item 2</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
## How drawer-toggle Works
|
||||
|
||||
### Mechanism
|
||||
|
||||
The `drawer-toggle` is a **hidden checkbox** that serves as the state controller:
|
||||
|
||||
```html
|
||||
<input id="my-drawer" type="checkbox" class="drawer-toggle" />
|
||||
```
|
||||
|
||||
### Toggle Behavior
|
||||
|
||||
1. **Label Connection**: Any `<label for="my-drawer">` element can toggle the drawer
|
||||
2. **Checkbox State**:
|
||||
- `checked` → drawer is open
|
||||
- `unchecked` → drawer is closed
|
||||
3. **CSS Targeting**: DaisyUI uses CSS sibling selectors to show/hide the drawer based on checkbox state
|
||||
4. **Accessibility**: Native checkbox provides keyboard accessibility (Space/Enter to toggle)
|
||||
|
||||
### Toggle Examples
|
||||
|
||||
```html
|
||||
<!-- Button to open drawer -->
|
||||
<label for="my-drawer" class="btn btn-primary drawer-button">
|
||||
Open Menu
|
||||
</label>
|
||||
|
||||
<!-- Close button inside drawer -->
|
||||
<label for="my-drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">✕</label>
|
||||
|
||||
<!-- Overlay to close (click outside) -->
|
||||
<label for="my-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
```
|
||||
|
||||
## Mobile Drawer (Overlay)
|
||||
|
||||
### Characteristics
|
||||
|
||||
- Drawer slides in from the side (usually left)
|
||||
- Overlays the main content
|
||||
- Dark overlay (drawer-overlay) behind drawer
|
||||
- Clicking overlay closes the drawer
|
||||
- Typically used on mobile/tablet screens
|
||||
|
||||
### Implementation
|
||||
|
||||
```html
|
||||
<div class="drawer">
|
||||
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content">
|
||||
<!-- Toggle button in header -->
|
||||
<div class="navbar bg-base-100">
|
||||
<div class="flex-none">
|
||||
<label for="mobile-drawer" class="btn btn-square btn-ghost">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost text-xl">My App</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="p-4">
|
||||
<h1>Main Content</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side">
|
||||
<!-- Overlay - clicking it closes the drawer -->
|
||||
<label for="mobile-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
|
||||
<!-- Sidebar menu -->
|
||||
<ul class="menu p-4 w-80 min-h-full bg-base-200">
|
||||
<li><a>Home</a></li>
|
||||
<li><a>About</a></li>
|
||||
<li><a>Contact</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Styling Notes
|
||||
|
||||
- **Width**: Default `w-80` (320px), adjust with Tailwind width utilities
|
||||
- **Background**: Use DaisyUI color classes like `bg-base-200`
|
||||
- **Height**: Always use `min-h-full` to ensure full height
|
||||
- **Padding**: Add `p-4` or similar for inner spacing
|
||||
|
||||
## Desktop Sidebar (Persistent)
|
||||
|
||||
### Characteristics
|
||||
|
||||
- Always visible (no overlay)
|
||||
- Does not overlay main content
|
||||
- Main content adjusts to sidebar width
|
||||
- No toggle button needed
|
||||
- Used on desktop screens
|
||||
|
||||
### Implementation with drawer-open
|
||||
|
||||
```html
|
||||
<div class="drawer lg:drawer-open">
|
||||
<input id="desktop-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content">
|
||||
<!-- Main content -->
|
||||
<div class="p-4">
|
||||
<h1>Main Content</h1>
|
||||
<p>The sidebar is always visible on desktop (lg and above)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side">
|
||||
<!-- No overlay needed for persistent sidebar -->
|
||||
<label for="desktop-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
|
||||
<!-- Sidebar menu -->
|
||||
<ul class="menu p-4 w-80 min-h-full bg-base-200">
|
||||
<li><a>Dashboard</a></li>
|
||||
<li><a>Settings</a></li>
|
||||
<li><a>Profile</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### How drawer-open Works
|
||||
|
||||
The `drawer-open` class forces the drawer to be **permanently open**:
|
||||
|
||||
```html
|
||||
<div class="drawer drawer-open">
|
||||
```
|
||||
|
||||
- Drawer is always visible
|
||||
- Cannot be toggled closed
|
||||
- `drawer-toggle` checkbox is ignored
|
||||
- `drawer-overlay` is not shown
|
||||
- Main content automatically shifts to accommodate sidebar width
|
||||
|
||||
### Responsive Usage
|
||||
|
||||
Use Tailwind breakpoint modifiers for responsive behavior:
|
||||
|
||||
```html
|
||||
<!-- Open on large screens and above -->
|
||||
<div class="drawer lg:drawer-open">
|
||||
|
||||
<!-- Open on medium screens and above -->
|
||||
<div class="drawer md:drawer-open">
|
||||
|
||||
<!-- Open on extra-large screens and above -->
|
||||
<div class="drawer xl:drawer-open">
|
||||
```
|
||||
|
||||
## Combined Mobile + Desktop Pattern (Recommended)
|
||||
|
||||
This is the **most common pattern** for responsive applications: mobile overlay + desktop persistent.
|
||||
|
||||
### Complete Implementation
|
||||
|
||||
```html
|
||||
<div class="drawer lg:drawer-open">
|
||||
<!-- Checkbox for mobile toggle -->
|
||||
<input id="app-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content flex flex-col">
|
||||
<!-- Navbar with mobile menu button -->
|
||||
<div class="navbar bg-base-100 lg:hidden">
|
||||
<div class="flex-none">
|
||||
<label for="app-drawer" class="btn btn-square btn-ghost">
|
||||
<!-- Hamburger icon -->
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" class="inline-block w-5 h-5 stroke-current">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||
</svg>
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex-1">
|
||||
<a class="btn btn-ghost text-xl">My App</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Main content -->
|
||||
<div class="flex-1 p-6">
|
||||
<h1 class="text-3xl font-bold mb-4">Welcome</h1>
|
||||
<p>This is the main content area.</p>
|
||||
<p>On mobile (< lg): sidebar is hidden, hamburger menu visible</p>
|
||||
<p>On desktop (≥ lg): sidebar is persistent, hamburger menu hidden</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side">
|
||||
<!-- Overlay only shows on mobile -->
|
||||
<label for="app-drawer" aria-label="close sidebar" class="drawer-overlay"></label>
|
||||
|
||||
<!-- Sidebar navigation -->
|
||||
<aside class="bg-base-200 w-80 min-h-full">
|
||||
<!-- Logo/Header area -->
|
||||
<div class="p-4 font-bold text-xl border-b border-base-300">
|
||||
My App Logo
|
||||
</div>
|
||||
|
||||
<!-- Navigation menu -->
|
||||
<ul class="menu p-4">
|
||||
<li><a>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</a></li>
|
||||
<li><a>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
Documents
|
||||
</a></li>
|
||||
<li><a>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
Settings
|
||||
</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Behavior Breakdown
|
||||
|
||||
#### On Mobile (< 1024px / < lg)
|
||||
1. Sidebar is hidden by default
|
||||
2. Hamburger button visible in navbar
|
||||
3. Clicking hamburger opens sidebar as overlay
|
||||
4. Clicking overlay or close button closes sidebar
|
||||
5. Sidebar slides in from left with animation
|
||||
|
||||
#### On Desktop (≥ 1024px / ≥ lg)
|
||||
1. `lg:drawer-open` keeps sidebar permanently visible
|
||||
2. Hamburger button hidden via `lg:hidden`
|
||||
3. Sidebar takes up fixed width (320px)
|
||||
4. Main content area adjusts automatically
|
||||
5. No overlay, no toggle needed
|
||||
|
||||
## Tailwind Breakpoints Reference
|
||||
|
||||
```css
|
||||
/* Default (mobile-first) */
|
||||
/* < 640px */
|
||||
|
||||
sm: /* ≥ 640px */
|
||||
md: /* ≥ 768px */
|
||||
lg: /* ≥ 1024px */ ← Common desktop breakpoint
|
||||
xl: /* ≥ 1280px */
|
||||
2xl: /* ≥ 1536px */
|
||||
```
|
||||
|
||||
## Key Classes Summary
|
||||
|
||||
| Class | Purpose |
|
||||
|-------|---------|
|
||||
| `drawer` | Main container |
|
||||
| `drawer-toggle` | Hidden checkbox for state control |
|
||||
| `drawer-content` | Main content area |
|
||||
| `drawer-side` | Sidebar container |
|
||||
| `drawer-overlay` | Clickable overlay (closes drawer) |
|
||||
| `drawer-open` | Forces drawer to stay open |
|
||||
| `drawer-end` | Positions drawer on the right side |
|
||||
| `lg:drawer-open` | Opens drawer on large screens only |
|
||||
|
||||
## Positioning Variants
|
||||
|
||||
### Left Side Drawer (Default)
|
||||
|
||||
```html
|
||||
<div class="drawer">
|
||||
<!-- Drawer appears on the left -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Right Side Drawer
|
||||
|
||||
```html
|
||||
<div class="drawer drawer-end">
|
||||
<!-- Drawer appears on the right -->
|
||||
</div>
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Accessibility
|
||||
- Always include `aria-label` on overlay: `<label for="drawer" aria-label="close sidebar" class="drawer-overlay"></label>`
|
||||
- Use semantic HTML (`<nav>`, `<aside>`)
|
||||
- Ensure keyboard navigation works (native checkbox provides this)
|
||||
|
||||
### 2. Responsive Design
|
||||
- Use `lg:drawer-open` for desktop persistence
|
||||
- Hide mobile toggle button on desktop: `lg:hidden`
|
||||
- Adjust sidebar width for mobile if needed: `w-64 md:w-80`
|
||||
|
||||
### 3. Performance
|
||||
- DaisyUI drawer is pure CSS (no JavaScript needed)
|
||||
- Animations are handled by CSS transitions
|
||||
- No performance overhead
|
||||
|
||||
### 4. Styling
|
||||
- Use DaisyUI theme colors: `bg-base-200`, `text-base-content`
|
||||
- Maintain consistent spacing: `p-4`, `gap-2`
|
||||
- Use DaisyUI menu component for navigation: `<ul class="menu">`
|
||||
|
||||
### 5. Content Structure
|
||||
```html
|
||||
<div class="drawer-content flex flex-col">
|
||||
<!-- Navbar (if needed) -->
|
||||
<div class="navbar">...</div>
|
||||
|
||||
<!-- Main content with flex-1 to fill space -->
|
||||
<div class="flex-1 p-6">
|
||||
<!-- Your content -->
|
||||
</div>
|
||||
|
||||
<!-- Footer (if needed) -->
|
||||
<footer>...</footer>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Pattern 1: Drawer with Close Button
|
||||
|
||||
```html
|
||||
<div class="drawer-side">
|
||||
<label for="drawer" class="drawer-overlay"></label>
|
||||
<aside class="bg-base-200 w-80 min-h-full relative">
|
||||
<!-- Close button (mobile only) -->
|
||||
<label for="drawer" class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2 lg:hidden">✕</label>
|
||||
|
||||
<!-- Sidebar content -->
|
||||
<ul class="menu p-4 pt-12">
|
||||
<li><a>Item 1</a></li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Pattern 2: Drawer with User Profile
|
||||
|
||||
```html
|
||||
<aside class="bg-base-200 w-80 min-h-full flex flex-col">
|
||||
<!-- Logo -->
|
||||
<div class="p-4 font-bold text-xl">My App</div>
|
||||
|
||||
<!-- Navigation (flex-1 to push footer down) -->
|
||||
<ul class="menu flex-1 p-4">
|
||||
<li><a>Dashboard</a></li>
|
||||
<li><a>Settings</a></li>
|
||||
</ul>
|
||||
|
||||
<!-- User profile footer -->
|
||||
<div class="p-4 border-t border-base-300">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="avatar">
|
||||
<div class="w-10 rounded-full">
|
||||
<img src="/avatar.jpg" alt="User" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="font-semibold">John Doe</div>
|
||||
<div class="text-sm opacity-70">john@example.com</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
```
|
||||
|
||||
### Pattern 3: Nested Menu with Submenu
|
||||
|
||||
```html
|
||||
<ul class="menu p-4 w-80 min-h-full bg-base-200">
|
||||
<li><a>Dashboard</a></li>
|
||||
|
||||
<!-- Submenu -->
|
||||
<li>
|
||||
<details>
|
||||
<summary>Products</summary>
|
||||
<ul>
|
||||
<li><a>Electronics</a></li>
|
||||
<li><a>Clothing</a></li>
|
||||
<li><a>Books</a></li>
|
||||
</ul>
|
||||
</details>
|
||||
</li>
|
||||
|
||||
<li><a>Settings</a></li>
|
||||
</ul>
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Drawer doesn't open on mobile
|
||||
**Solution**: Check that:
|
||||
1. Checkbox `id` matches label `for` attribute
|
||||
2. Checkbox has class `drawer-toggle`
|
||||
3. You're not using `drawer-open` on mobile breakpoints
|
||||
|
||||
### Issue: Drawer overlaps content on desktop
|
||||
**Solution**:
|
||||
- Remove `drawer-open` or use responsive variant `lg:drawer-open`
|
||||
- Ensure you want overlay behavior, not persistent sidebar
|
||||
|
||||
### Issue: Overlay not clickable
|
||||
**Solution**:
|
||||
- Ensure overlay label has correct `for` attribute
|
||||
- Check that overlay is not behind other elements (z-index)
|
||||
|
||||
### Issue: Content jumps when drawer opens
|
||||
**Solution**:
|
||||
- Add `flex flex-col` to `drawer-content`
|
||||
- Ensure drawer-side width is fixed (e.g., `w-80`)
|
||||
|
||||
## Migration from Custom Solutions
|
||||
|
||||
If migrating from a custom sidebar implementation:
|
||||
|
||||
### Replace custom JavaScript
|
||||
❌ Before:
|
||||
```javascript
|
||||
function toggleDrawer() {
|
||||
document.getElementById('sidebar').classList.toggle('open');
|
||||
}
|
||||
```
|
||||
|
||||
✅ After:
|
||||
```html
|
||||
<input id="drawer" type="checkbox" class="drawer-toggle" />
|
||||
<label for="drawer">Toggle</label>
|
||||
```
|
||||
|
||||
### Replace custom CSS
|
||||
❌ Before:
|
||||
```css
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
.sidebar.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
```
|
||||
|
||||
✅ After:
|
||||
```html
|
||||
<div class="drawer">
|
||||
<!-- DaisyUI handles all transitions -->
|
||||
</div>
|
||||
```
|
||||
|
||||
### Replace media query logic
|
||||
❌ Before:
|
||||
```css
|
||||
@media (min-width: 1024px) {
|
||||
.sidebar { display: block; }
|
||||
.toggle-button { display: none; }
|
||||
}
|
||||
```
|
||||
|
||||
✅ After:
|
||||
```html
|
||||
<div class="drawer lg:drawer-open">
|
||||
<label for="drawer" class="lg:hidden">Toggle</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Summary
|
||||
|
||||
The DaisyUI drawer pattern provides:
|
||||
|
||||
✅ **Zero JavaScript** - Pure CSS solution
|
||||
✅ **Accessible** - Built-in keyboard support via checkbox
|
||||
✅ **Responsive** - Easy mobile/desktop variants with Tailwind
|
||||
✅ **Themeable** - Uses DaisyUI theme colors
|
||||
✅ **Flexible** - Supports left/right positioning
|
||||
✅ **Standard** - No custom CSS needed
|
||||
|
||||
**Recommended approach**: Use `lg:drawer-open` for desktop with hidden mobile toggle for best responsive experience.
|
||||
|
||||
746
docs/sidebar-analysis-current-state.md
Normal file
746
docs/sidebar-analysis-current-state.md
Normal file
|
|
@ -0,0 +1,746 @@
|
|||
# Sidebar Analysis - Current State
|
||||
|
||||
**Erstellt:** 2025-12-16
|
||||
**Status:** Analyse für Neuimplementierung
|
||||
**Autor:** Cursor AI Assistant
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
Die aktuelle Sidebar-Implementierung verwendet **nicht existierende Custom-CSS-Variants** (`is-drawer-close:` und `is-drawer-open:`), was zu einer defekten Implementierung führt. Die Sidebar ist strukturell basierend auf DaisyUI's Drawer-Komponente, aber die responsive und state-basierte Funktionalität ist nicht funktionsfähig.
|
||||
|
||||
**Kritisches Problem:** Die im Code verwendeten Variants `is-drawer-close:*` und `is-drawer-open:*` sind **nicht in Tailwind konfiguriert**, was bedeutet, dass diese Klassen beim Build ignoriert werden.
|
||||
|
||||
---
|
||||
|
||||
## 1. Dateien-Übersicht
|
||||
|
||||
### 1.1 Hauptdateien
|
||||
|
||||
| Datei | Zweck | Zeilen | Status |
|
||||
|-------|-------|--------|--------|
|
||||
| `lib/mv_web/components/layouts/sidebar.ex` | Sidebar-Komponente (Elixir) | 198 | ⚠️ Verwendet nicht existierende Variants |
|
||||
| `lib/mv_web/components/layouts/navbar.ex` | Navbar mit Sidebar-Toggle | 48 | ✅ Funktional |
|
||||
| `lib/mv_web/components/layouts.ex` | Layout-Wrapper mit Drawer | 121 | ✅ Funktional |
|
||||
| `assets/js/app.js` | JavaScript für Sidebar-Interaktivität | 272 | ✅ Umfangreiche Accessibility-Logik |
|
||||
| `assets/css/app.css` | CSS-Konfiguration | 103 | ⚠️ Keine Drawer-Variants definiert |
|
||||
| `assets/tailwind.config.js` | Tailwind-Konfiguration | 75 | ⚠️ Keine Drawer-Variants definiert |
|
||||
|
||||
### 1.2 Verwandte Dateien
|
||||
|
||||
- `lib/mv_web/components/layouts/root.html.heex` - Root-Layout (minimal, keine Sidebar-Logik)
|
||||
- `priv/static/images/logo.svg` - Logo (wird vermutlich für Sidebar benötigt)
|
||||
|
||||
---
|
||||
|
||||
## 2. Aktuelle Struktur
|
||||
|
||||
### 2.1 HTML-Struktur (DaisyUI Drawer Pattern)
|
||||
|
||||
```html
|
||||
<!-- In layouts.ex -->
|
||||
<div class="drawer">
|
||||
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<div class="drawer-content">
|
||||
<!-- Navbar mit Toggle-Button -->
|
||||
<navbar with sidebar-toggle button />
|
||||
|
||||
<!-- Hauptinhalt -->
|
||||
<main>...</main>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="drawer-side">
|
||||
<button class="drawer-overlay" onclick="close drawer"></button>
|
||||
<nav id="main-sidebar">
|
||||
<!-- Navigation Items -->
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Bewertung:** ✅ Korrekte DaisyUI Drawer-Struktur
|
||||
|
||||
### 2.2 Sidebar-Komponente (`sidebar.ex`)
|
||||
|
||||
**Struktur:**
|
||||
```elixir
|
||||
defmodule MvWeb.Layouts.Sidebar do
|
||||
attr :current_user, :map
|
||||
attr :club_name, :string
|
||||
|
||||
def sidebar(assigns) do
|
||||
# Rendert Sidebar mit Navigation, Locale-Selector, Theme-Toggle, User-Menu
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
**Hauptelemente:**
|
||||
1. **Drawer Overlay** - Button zum Schließen (Mobile)
|
||||
2. **Navigation Container** (`<nav id="main-sidebar">`)
|
||||
3. **Menü-Items** - Members, Users, Contributions (nested), Settings
|
||||
4. **Footer-Bereich** - Locale-Selector, Theme-Toggle, User-Menu
|
||||
|
||||
---
|
||||
|
||||
## 3. Custom CSS Variants - KRITISCHES PROBLEM
|
||||
|
||||
### 3.1 Verwendete Variants im Code
|
||||
|
||||
Die Sidebar verwendet folgende Custom-Variants **extensiv**:
|
||||
|
||||
```elixir
|
||||
# Beispiele aus sidebar.ex
|
||||
"is-drawer-close:overflow-visible"
|
||||
"is-drawer-close:w-14 is-drawer-open:w-64"
|
||||
"is-drawer-close:hidden"
|
||||
"is-drawer-close:tooltip is-drawer-close:tooltip-right"
|
||||
"is-drawer-close:w-auto"
|
||||
"is-drawer-close:justify-center"
|
||||
"is-drawer-close:dropdown-end"
|
||||
```
|
||||
|
||||
**Gefundene Verwendungen:**
|
||||
- `is-drawer-close:` - 13 Instanzen in sidebar.ex
|
||||
- `is-drawer-open:` - 1 Instanz in sidebar.ex
|
||||
|
||||
### 3.2 Definition der Variants
|
||||
|
||||
**❌ NICHT GEFUNDEN in:**
|
||||
- `assets/css/app.css` - Enthält nur `phx-*-loading` Variants
|
||||
- `assets/tailwind.config.js` - Enthält nur `phx-*-loading` Variants
|
||||
|
||||
**Fazit:** Diese Variants existieren **nicht** und werden beim Tailwind-Build **ignoriert**!
|
||||
|
||||
### 3.3 Vorhandene Variants
|
||||
|
||||
Nur folgende Custom-Variants sind tatsächlich definiert:
|
||||
|
||||
```css
|
||||
/* In app.css (Tailwind CSS 4.x Syntax) */
|
||||
@custom-variant phx-click-loading (.phx-click-loading&, .phx-click-loading &);
|
||||
@custom-variant phx-submit-loading (.phx-submit-loading&, .phx-submit-loading &);
|
||||
@custom-variant phx-change-loading (.phx-change-loading&, .phx-change-loading &);
|
||||
```
|
||||
|
||||
```javascript
|
||||
/* In tailwind.config.js (Tailwind 3.x Kompatibilität) */
|
||||
plugin(({addVariant}) => addVariant("phx-click-loading", [...])),
|
||||
plugin(({addVariant}) => addVariant("phx-submit-loading", [...])),
|
||||
plugin(({addVariant}) => addVariant("phx-change-loading", [...])),
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. JavaScript-Implementierung
|
||||
|
||||
### 4.1 Übersicht
|
||||
|
||||
Die JavaScript-Implementierung ist **sehr umfangreich** und fokussiert auf Accessibility:
|
||||
|
||||
**Datei:** `assets/js/app.js` (Zeilen 106-270)
|
||||
|
||||
**Hauptfunktionalitäten:**
|
||||
1. ✅ Tabindex-Management für fokussierbare Elemente
|
||||
2. ✅ ARIA-Attribut-Management (`aria-expanded`)
|
||||
3. ✅ Keyboard-Navigation (Enter, Space, Escape)
|
||||
4. ✅ Focus-Management beim Öffnen/Schließen
|
||||
5. ✅ Dropdown-Integration
|
||||
|
||||
### 4.2 Wichtige JavaScript-Funktionen
|
||||
|
||||
#### 4.2.1 Tabindex-Management
|
||||
|
||||
```javascript
|
||||
const updateSidebarTabIndex = (isOpen) => {
|
||||
const allFocusableElements = sidebar.querySelectorAll(
|
||||
'a[href], button, select, input:not([type="hidden"]), [tabindex]'
|
||||
)
|
||||
|
||||
allFocusableElements.forEach(el => {
|
||||
if (isOpen) {
|
||||
// Make focusable when open
|
||||
el.removeAttribute('tabindex')
|
||||
} else {
|
||||
// Remove from tab order when closed
|
||||
el.setAttribute('tabindex', '-1')
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Zweck:** Verhindert, dass Nutzer mit Tab zu unsichtbaren Sidebar-Elementen springen können.
|
||||
|
||||
#### 4.2.2 ARIA-Expanded Management
|
||||
|
||||
```javascript
|
||||
const updateAriaExpanded = () => {
|
||||
const isOpen = drawerToggle.checked
|
||||
sidebarToggle.setAttribute("aria-expanded", isOpen.toString())
|
||||
}
|
||||
```
|
||||
|
||||
**Zweck:** Informiert Screen-Reader über den Sidebar-Status.
|
||||
|
||||
#### 4.2.3 Focus-Management
|
||||
|
||||
```javascript
|
||||
const getFirstFocusableElement = () => {
|
||||
// Priority: navigation link > other links > other focusable
|
||||
const firstNavLink = sidebar.querySelector('a[href][role="menuitem"]')
|
||||
// ... fallback logic
|
||||
}
|
||||
|
||||
// On open: focus first element
|
||||
// On close: focus toggle button
|
||||
```
|
||||
|
||||
**Zweck:** Logische Fokus-Reihenfolge für Keyboard-Navigation.
|
||||
|
||||
#### 4.2.4 Keyboard-Shortcuts
|
||||
|
||||
```javascript
|
||||
// ESC to close
|
||||
document.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Escape" && drawerToggle.checked) {
|
||||
drawerToggle.checked = false
|
||||
sidebarToggle.focus()
|
||||
}
|
||||
})
|
||||
|
||||
// Enter/Space on toggle button
|
||||
sidebarToggle.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter" || e.key === " ") {
|
||||
// Toggle drawer and manage focus
|
||||
}
|
||||
})
|
||||
```
|
||||
|
||||
### 4.3 LiveView Hooks
|
||||
|
||||
**Definierte Hooks:**
|
||||
```javascript
|
||||
Hooks.CopyToClipboard = { ... } // Clipboard-Funktionalität
|
||||
Hooks.ComboBox = { ... } // Dropdown-Prävention bei Enter
|
||||
```
|
||||
|
||||
**Sidebar-spezifisch:** Keine Hooks, nur native DOM-Events.
|
||||
|
||||
---
|
||||
|
||||
## 5. DaisyUI Dependencies
|
||||
|
||||
### 5.1 Verwendete DaisyUI-Komponenten
|
||||
|
||||
| Komponente | Verwendung | Klassen |
|
||||
|------------|-----------|---------|
|
||||
| **Drawer** | Basis-Layout | `drawer`, `drawer-toggle`, `drawer-side`, `drawer-content`, `drawer-overlay` |
|
||||
| **Menu** | Navigation | `menu`, `menu-title`, `w-64` |
|
||||
| **Button** | Toggle, User-Menu | `btn`, `btn-ghost`, `btn-square`, `btn-circle` |
|
||||
| **Avatar** | User-Menu | `avatar`, `avatar-placeholder` |
|
||||
| **Dropdown** | User-Menu | `dropdown`, `dropdown-top`, `dropdown-end`, `dropdown-content` |
|
||||
| **Tooltip** | Icon-Tooltips | `tooltip`, `tooltip-right` (via `data-tip`) |
|
||||
| **Select** | Locale-Selector | `select`, `select-sm` |
|
||||
| **Toggle** | Theme-Switch | `toggle`, `theme-controller` |
|
||||
|
||||
### 5.2 Standard Tailwind-Klassen
|
||||
|
||||
**Layout:**
|
||||
- `flex`, `flex-col`, `items-start`, `justify-center`
|
||||
- `gap-2`, `gap-4`, `p-4`, `mt-auto`, `w-full`, `w-64`, `min-h-full`
|
||||
|
||||
**Sizing:**
|
||||
- `size-4`, `size-5`, `w-12`, `w-52`
|
||||
|
||||
**Colors:**
|
||||
- `bg-base-100`, `bg-base-200`, `text-neutral-content`
|
||||
|
||||
**Typography:**
|
||||
- `text-lg`, `text-sm`, `font-bold`
|
||||
|
||||
**Accessibility:**
|
||||
- `sr-only`, `focus:outline-none`, `focus:ring-2`, `focus:ring-primary`
|
||||
|
||||
---
|
||||
|
||||
## 6. Toggle-Button (Navbar)
|
||||
|
||||
### 6.1 Implementierung
|
||||
|
||||
**Datei:** `lib/mv_web/components/layouts/navbar.ex`
|
||||
|
||||
```elixir
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('main-drawer').checked = !document.getElementById('main-drawer').checked"
|
||||
aria-label={gettext("Toggle navigation menu")}
|
||||
aria-expanded="false"
|
||||
aria-controls="main-sidebar"
|
||||
id="sidebar-toggle"
|
||||
class="mr-2 btn btn-square btn-ghost"
|
||||
>
|
||||
<svg><!-- Layout-Panel-Left Icon --></svg>
|
||||
</button>
|
||||
```
|
||||
|
||||
**Funktionalität:**
|
||||
- ✅ Togglet Drawer-Checkbox
|
||||
- ✅ ARIA-Labels vorhanden
|
||||
- ✅ Keyboard-accessible
|
||||
- ⚠️ `aria-expanded` wird durch JavaScript aktualisiert
|
||||
|
||||
**Icon:** Custom SVG (Layout-Panel-Left mit Chevron-Right)
|
||||
|
||||
---
|
||||
|
||||
## 7. Responsive Verhalten
|
||||
|
||||
### 7.1 Aktuelles Konzept (nicht funktional)
|
||||
|
||||
**Versuchte Implementierung:**
|
||||
- **Desktop (collapsed):** Sidebar mit 14px Breite (`is-drawer-close:w-14`)
|
||||
- **Desktop (expanded):** Sidebar mit 64px Breite (`is-drawer-open:w-64`)
|
||||
- **Mobile:** Overlay-Drawer (DaisyUI Standard)
|
||||
|
||||
### 7.2 Problem
|
||||
|
||||
Da die `is-drawer-*` Variants nicht existieren, gibt es **kein responsives Verhalten**:
|
||||
- Die Sidebar hat immer eine feste Breite von `w-64`
|
||||
- Die conditional hiding (`:hidden`, etc.) funktioniert nicht
|
||||
- Tooltips werden nicht conditional angezeigt
|
||||
|
||||
---
|
||||
|
||||
## 8. Accessibility-Features
|
||||
|
||||
### 8.1 Implementierte Features
|
||||
|
||||
| Feature | Status | Implementierung |
|
||||
|---------|--------|-----------------|
|
||||
| **ARIA Labels** | ✅ | Alle interaktiven Elemente haben Labels |
|
||||
| **ARIA Roles** | ✅ | `menubar`, `menuitem`, `menu`, `button` |
|
||||
| **ARIA Expanded** | ✅ | Wird durch JS dynamisch gesetzt |
|
||||
| **ARIA Controls** | ✅ | Toggle → Sidebar verknüpft |
|
||||
| **Keyboard Navigation** | ✅ | Enter, Space, Escape, Tab |
|
||||
| **Focus Management** | ✅ | Logische Focus-Reihenfolge |
|
||||
| **Tabindex Management** | ✅ | Verhindert Focus auf hidden Elements |
|
||||
| **Screen Reader Only** | ✅ | `.sr-only` für visuelle Labels |
|
||||
| **Focus Indicators** | ✅ | `focus:ring-2 focus:ring-primary` |
|
||||
| **Skip Links** | ❌ | Nicht vorhanden |
|
||||
|
||||
### 8.2 Accessibility-Score
|
||||
|
||||
**Geschätzt:** 90/100 (WCAG 2.1 Level AA konform)
|
||||
|
||||
**Verbesserungspotenzial:**
|
||||
- Skip-Link zur Hauptnavigation hinzufügen
|
||||
- High-Contrast-Mode testen
|
||||
|
||||
---
|
||||
|
||||
## 9. Menü-Struktur
|
||||
|
||||
### 9.1 Navigation Items
|
||||
|
||||
```
|
||||
📋 Main Menu
|
||||
├── 👥 Members (/members)
|
||||
├── 👤 Users (/users)
|
||||
├── 💰 Contributions (collapsed submenu)
|
||||
│ ├── Plans (/contribution_types)
|
||||
│ └── Settings (/contribution_settings)
|
||||
└── ⚙️ Settings (/settings)
|
||||
|
||||
🔽 Footer Area (logged in only)
|
||||
├── 🌐 Locale Selector (DE/EN)
|
||||
├── 🌓 Theme Toggle (Light/Dark)
|
||||
└── 👤 User Menu (Dropdown)
|
||||
├── Profile (/users/:id)
|
||||
└── Logout (/sign-out)
|
||||
```
|
||||
|
||||
### 9.2 Conditional Rendering
|
||||
|
||||
**Nicht eingeloggt:**
|
||||
- Sidebar ist leer (nur Struktur)
|
||||
- Keine Menü-Items
|
||||
|
||||
**Eingeloggt:**
|
||||
- Vollständige Navigation
|
||||
- Footer-Bereich mit User-Menu
|
||||
|
||||
### 9.3 Nested Menu (Contributions)
|
||||
|
||||
**Problem:** Das Contributions-Submenu ist **immer versteckt** im collapsed State:
|
||||
|
||||
```elixir
|
||||
<li class="is-drawer-close:hidden" role="none">
|
||||
<h2 class="flex items-center gap-2 menu-title">
|
||||
<.icon name="hero-currency-dollar" />
|
||||
{gettext("Contributions")}
|
||||
</h2>
|
||||
<ul role="menu">
|
||||
<li class="is-drawer-close:hidden">...</li>
|
||||
<li class="is-drawer-close:hidden">...</li>
|
||||
</ul>
|
||||
</li>
|
||||
```
|
||||
|
||||
Da `:hidden` nicht funktioniert, wird das Submenu immer angezeigt.
|
||||
|
||||
---
|
||||
|
||||
## 10. Theme-Funktionalität
|
||||
|
||||
### 10.1 Theme-Toggle
|
||||
|
||||
```elixir
|
||||
<input
|
||||
type="checkbox"
|
||||
value="dark"
|
||||
class="toggle theme-controller"
|
||||
aria-label={gettext("Toggle dark mode")}
|
||||
/>
|
||||
```
|
||||
|
||||
**Funktionalität:**
|
||||
- ✅ DaisyUI `theme-controller` - automatische Theme-Umschaltung
|
||||
- ✅ Persistence durch `localStorage` (siehe root.html.heex Script)
|
||||
- ✅ Icon-Wechsel (Sun ↔ Moon)
|
||||
|
||||
### 10.2 Definierte Themes
|
||||
|
||||
**Datei:** `assets/css/app.css`
|
||||
|
||||
1. **Light Theme** (default)
|
||||
- Base: `oklch(98% 0 0)`
|
||||
- Primary: `oklch(70% 0.213 47.604)` (Orange/Phoenix-inspiriert)
|
||||
|
||||
2. **Dark Theme**
|
||||
- Base: `oklch(30.33% 0.016 252.42)`
|
||||
- Primary: `oklch(58% 0.233 277.117)` (Purple/Elixir-inspiriert)
|
||||
|
||||
---
|
||||
|
||||
## 11. Locale-Funktionalität
|
||||
|
||||
### 11.1 Locale-Selector
|
||||
|
||||
```elixir
|
||||
<form method="post" action="/set_locale">
|
||||
<select
|
||||
id="locale-select-sidebar"
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm w-full is-drawer-close:w-auto"
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
</select>
|
||||
</form>
|
||||
```
|
||||
|
||||
**Funktionalität:**
|
||||
- ✅ POST zu `/set_locale` Endpoint
|
||||
- ✅ CSRF-Token included
|
||||
- ✅ Auto-Submit on change
|
||||
- ✅ Accessible Label (`.sr-only`)
|
||||
|
||||
---
|
||||
|
||||
## 12. Probleme und Defekte
|
||||
|
||||
### 12.1 Kritische Probleme
|
||||
|
||||
| Problem | Schweregrad | Details |
|
||||
|---------|-------------|---------|
|
||||
| **Nicht existierende CSS-Variants** | 🔴 Kritisch | `is-drawer-close:*` und `is-drawer-open:*` sind nicht definiert |
|
||||
| **Keine responsive Funktionalität** | 🔴 Kritisch | Sidebar verhält sich nicht wie geplant |
|
||||
| **Conditional Styles funktionieren nicht** | 🔴 Kritisch | Hidden/Tooltip/Width-Changes werden ignoriert |
|
||||
|
||||
### 12.2 Mittlere Probleme
|
||||
|
||||
| Problem | Schweregrad | Details |
|
||||
|---------|-------------|---------|
|
||||
| **Kein Logo** | 🟡 Mittel | Logo-Element fehlt komplett in der Sidebar |
|
||||
| **Submenu immer sichtbar** | 🟡 Mittel | Contributions-Submenu sollte in collapsed State versteckt sein |
|
||||
| **Toggle-Icon statisch** | 🟡 Mittel | Icon ändert sich nicht zwischen expanded/collapsed |
|
||||
|
||||
### 12.3 Kleinere Probleme
|
||||
|
||||
| Problem | Schweregrad | Details |
|
||||
|---------|-------------|---------|
|
||||
| **Code-Redundanz** | 🟢 Klein | Variants in beiden Tailwind-Configs (3.x und 4.x) |
|
||||
| **Inline-onclick Handler** | 🟢 Klein | Sollten durch JS-Events ersetzt werden |
|
||||
| **Keine Skip-Links** | 🟢 Klein | Accessibility-Verbesserung |
|
||||
|
||||
---
|
||||
|
||||
## 13. Abhängigkeiten
|
||||
|
||||
### 13.1 Externe Abhängigkeiten
|
||||
|
||||
| Dependency | Version | Verwendung |
|
||||
|------------|---------|------------|
|
||||
| **DaisyUI** | Latest (vendor) | Drawer, Menu, Button, etc. |
|
||||
| **Tailwind CSS** | 4.0.9 | Utility-Klassen |
|
||||
| **Heroicons** | v2.2.0 | Icons in Navigation |
|
||||
| **Phoenix LiveView** | ~> 1.1.0 | Backend-Integration |
|
||||
|
||||
### 13.2 Interne Abhängigkeiten
|
||||
|
||||
| Modul | Verwendung |
|
||||
|-------|-----------|
|
||||
| `MvWeb.Gettext` | Internationalisierung |
|
||||
| `Mv.Membership.get_settings()` | Club-Name abrufen |
|
||||
| `MvWeb.CoreComponents` | Icons, Links |
|
||||
|
||||
---
|
||||
|
||||
## 14. Code-Qualität
|
||||
|
||||
### 14.1 Positives
|
||||
|
||||
- ✅ **Sehr gute Accessibility-Implementierung**
|
||||
- ✅ **Saubere Modulstruktur** (Separation of Concerns)
|
||||
- ✅ **Gute Dokumentation** (Moduledocs, Attribute docs)
|
||||
- ✅ **Internationalisierung** vollständig implementiert
|
||||
- ✅ **ARIA-Best-Practices** befolgt
|
||||
- ✅ **Keyboard-Navigation** umfassend
|
||||
|
||||
### 14.2 Verbesserungsbedarf
|
||||
|
||||
- ❌ **Broken CSS-Variants** (Hauptproblem)
|
||||
- ❌ **Fehlende Tests** (keine Component-Tests gefunden)
|
||||
- ⚠️ **Inline-JavaScript** in onclick-Attributen
|
||||
- ⚠️ **Magic-IDs** (`main-drawer`, `sidebar-toggle`) hardcoded
|
||||
- ⚠️ **Komplexe JavaScript-Logik** ohne Dokumentation
|
||||
|
||||
---
|
||||
|
||||
## 15. Empfehlungen für Neuimplementierung
|
||||
|
||||
### 15.1 Sofort-Maßnahmen
|
||||
|
||||
1. **CSS-Variants entfernen**
|
||||
- Alle `is-drawer-close:*` und `is-drawer-open:*` entfernen
|
||||
- Durch Standard-Tailwind oder DaisyUI-Mechanismen ersetzen
|
||||
|
||||
2. **Logo hinzufügen**
|
||||
- Logo-Element als erstes Element in Sidebar
|
||||
- Konsistente Größe (32px / size-8)
|
||||
|
||||
3. **Toggle-Icon implementieren**
|
||||
- Icon-Swap zwischen Chevron-Left und Chevron-Right
|
||||
- Nur auf Desktop sichtbar
|
||||
|
||||
### 15.2 Architektur-Entscheidungen
|
||||
|
||||
1. **Responsive Strategie:**
|
||||
- **Mobile:** Standard DaisyUI Drawer (Overlay)
|
||||
- **Desktop:** Persistent Sidebar mit fester Breite
|
||||
- **Kein collapsing auf Desktop** (einfacher, wartbarer)
|
||||
|
||||
2. **State-Management:**
|
||||
- Drawer-Checkbox für Mobile
|
||||
- Keine zusätzlichen Custom-Variants
|
||||
- Standard DaisyUI-Mechanismen verwenden
|
||||
|
||||
3. **JavaScript-Refactoring:**
|
||||
- Hooks statt inline-onclick
|
||||
- Dokumentierte Funktionen
|
||||
- Unit-Tests für kritische Logik
|
||||
|
||||
### 15.3 Prioritäten
|
||||
|
||||
**High Priority:**
|
||||
1. CSS-Variants-Problem lösen
|
||||
2. Logo implementieren
|
||||
3. Basic responsive Funktionalität
|
||||
|
||||
**Medium Priority:**
|
||||
4. Toggle-Icon implementieren
|
||||
5. Tests schreiben
|
||||
6. JavaScript refactoren
|
||||
|
||||
**Low Priority:**
|
||||
7. Skip-Links hinzufügen
|
||||
8. Code-Optimierung
|
||||
9. Performance-Tuning
|
||||
|
||||
---
|
||||
|
||||
## 16. Checkliste für Neuimplementierung
|
||||
|
||||
### 16.1 Vorbereitung
|
||||
|
||||
- [ ] Alle `is-drawer-*` Klassen aus Code entfernen
|
||||
- [ ] Keine Custom-Variants in CSS/Tailwind definieren
|
||||
- [ ] DaisyUI-Dokumentation für Drawer studieren
|
||||
|
||||
### 16.2 Implementation
|
||||
|
||||
- [ ] Logo-Element hinzufügen (size-8, persistent)
|
||||
- [ ] Toggle-Button mit Icon-Swap (nur Desktop)
|
||||
- [ ] Mobile: Overlay-Drawer (DaisyUI Standard)
|
||||
- [ ] Desktop: Persistent Sidebar (w-64)
|
||||
- [ ] Menü-Items mit korrekten Klassen
|
||||
- [ ] Submenu-Handling (nested `<ul>`)
|
||||
|
||||
### 16.3 Funktionalität
|
||||
|
||||
- [ ] Toggle-Funktionalität auf Mobile
|
||||
- [ ] Accessibility: ARIA, Focus, Keyboard
|
||||
- [ ] Theme-Toggle funktional
|
||||
- [ ] Locale-Selector funktional
|
||||
- [ ] User-Menu-Dropdown funktional
|
||||
|
||||
### 16.4 Testing
|
||||
|
||||
- [ ] Component-Tests schreiben
|
||||
- [ ] Accessibility-Tests (axe-core)
|
||||
- [ ] Keyboard-Navigation testen
|
||||
- [ ] Screen-Reader testen
|
||||
- [ ] Responsive Breakpoints testen
|
||||
|
||||
### 16.5 Dokumentation
|
||||
|
||||
- [ ] Code-Kommentare aktualisieren
|
||||
- [ ] Component-Docs schreiben
|
||||
- [ ] README aktualisieren
|
||||
|
||||
---
|
||||
|
||||
## 17. Technische Details
|
||||
|
||||
### 17.1 CSS-Selektoren
|
||||
|
||||
**Verwendete IDs:**
|
||||
- `#main-drawer` - Drawer-Toggle-Checkbox
|
||||
- `#main-sidebar` - Sidebar-Navigation-Container
|
||||
- `#sidebar-toggle` - Toggle-Button in Navbar
|
||||
- `#locale-select-sidebar` - Locale-Dropdown
|
||||
|
||||
**Verwendete Klassen:**
|
||||
- `.drawer-side` - DaisyUI Sidebar-Container
|
||||
- `.drawer-overlay` - DaisyUI Overlay-Button
|
||||
- `.drawer-content` - DaisyUI Content-Container
|
||||
- `.menu` - DaisyUI Menu-Container
|
||||
- `.is-drawer-close:*` - ❌ NICHT DEFINIERT
|
||||
- `.is-drawer-open:*` - ❌ NICHT DEFINIERT
|
||||
|
||||
### 17.2 Event-Handler
|
||||
|
||||
**JavaScript:**
|
||||
```javascript
|
||||
drawerToggle.addEventListener("change", ...)
|
||||
sidebarToggle.addEventListener("click", ...)
|
||||
sidebarToggle.addEventListener("keydown", ...)
|
||||
document.addEventListener("keydown", ...) // ESC handler
|
||||
```
|
||||
|
||||
**Inline (zu migrieren):**
|
||||
```elixir
|
||||
onclick="document.getElementById('main-drawer').checked = false"
|
||||
onclick="document.getElementById('main-drawer').checked = !..."
|
||||
onchange="this.form.submit()"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 18. Metriken
|
||||
|
||||
### 18.1 Code-Metriken
|
||||
|
||||
| Metrik | Wert |
|
||||
|--------|------|
|
||||
| **Zeilen Code (Sidebar)** | 198 |
|
||||
| **Zeilen JavaScript** | 165 (Sidebar-spezifisch) |
|
||||
| **Zeilen CSS** | 0 (nur Tailwind-Klassen) |
|
||||
| **Anzahl Komponenten** | 1 (Sidebar) + 1 (Navbar) |
|
||||
| **Anzahl Menü-Items** | 6 (inkl. Submenu) |
|
||||
| **Anzahl Footer-Controls** | 3 (Locale, Theme, User) |
|
||||
|
||||
### 18.2 Abhängigkeits-Metriken
|
||||
|
||||
| Kategorie | Anzahl |
|
||||
|-----------|--------|
|
||||
| **DaisyUI-Komponenten** | 7 |
|
||||
| **Tailwind-Utility-Klassen** | ~50 |
|
||||
| **Custom-Variants (broken)** | 2 (`is-drawer-close`, `is-drawer-open`) |
|
||||
| **JavaScript-Event-Listener** | 6 |
|
||||
| **ARIA-Attribute** | 12 |
|
||||
|
||||
---
|
||||
|
||||
## 19. Zusammenfassung
|
||||
|
||||
### 19.1 Was funktioniert
|
||||
|
||||
✅ **Sehr gute Grundlage:**
|
||||
- DaisyUI Drawer-Pattern korrekt implementiert
|
||||
- Exzellente Accessibility (ARIA, Keyboard, Focus)
|
||||
- Saubere Modulstruktur
|
||||
- Internationalisierung
|
||||
- Theme-Switching
|
||||
- JavaScript-Logik ist robust
|
||||
|
||||
### 19.2 Was nicht funktioniert
|
||||
|
||||
❌ **Kritische Defekte:**
|
||||
- CSS-Variants existieren nicht → keine responsive Funktionalität
|
||||
- Kein Logo
|
||||
- Kein Toggle-Icon-Swap
|
||||
- Submenu-Handling defekt
|
||||
|
||||
### 19.3 Nächste Schritte
|
||||
|
||||
1. **CSS-Variants entfernen** (alle `is-drawer-*` Klassen)
|
||||
2. **Standard DaisyUI-Pattern verwenden** (ohne Custom-Variants)
|
||||
3. **Logo hinzufügen** (persistent, size-8)
|
||||
4. **Simplify:** Mobile = Overlay, Desktop = Persistent (keine collapsed State)
|
||||
5. **Tests schreiben** (Component + Accessibility)
|
||||
|
||||
---
|
||||
|
||||
## 20. Anhang
|
||||
|
||||
### 20.1 Verwendete CSS-Klassen (alphabetisch)
|
||||
|
||||
```
|
||||
avatar, avatar-placeholder, bg-base-100, bg-base-200, bg-neutral,
|
||||
btn, btn-circle, btn-ghost, btn-square, cursor-pointer, drawer,
|
||||
drawer-content, drawer-overlay, drawer-side, drawer-toggle, dropdown,
|
||||
dropdown-content, dropdown-end, dropdown-top, flex, flex-col,
|
||||
focus:outline-none, focus:ring-2, focus:ring-primary,
|
||||
focus-within:outline-none, focus-within:ring-2, gap-2, gap-4,
|
||||
is-drawer-close:*, is-drawer-open:*, items-center, items-start,
|
||||
mb-2, menu, menu-sm, menu-title, min-h-full, mr-2, mt-3, mt-auto,
|
||||
p-2, p-4, rounded-box, rounded-full, select, select-sm, shadow,
|
||||
shadow-sm, size-4, size-5, sr-only, text-lg, text-neutral-content,
|
||||
text-sm, theme-controller, toggle, tooltip, tooltip-right, w-12,
|
||||
w-52, w-64, w-full, z-1
|
||||
```
|
||||
|
||||
### 20.2 Verwendete ARIA-Attribute
|
||||
|
||||
```
|
||||
aria-busy, aria-controls, aria-describedby, aria-expanded,
|
||||
aria-haspopup, aria-hidden, aria-label, aria-labelledby,
|
||||
aria-live, role="alert", role="button", role="menu",
|
||||
role="menubar", role="menuitem", role="none", role="status"
|
||||
```
|
||||
|
||||
### 20.3 Relevante Links
|
||||
|
||||
- [DaisyUI Drawer Docs](https://daisyui.com/components/drawer/)
|
||||
- [Tailwind CSS Custom Variants](https://tailwindcss.com/docs/adding-custom-styles#adding-custom-variants)
|
||||
- [WCAG 2.1 Guidelines](https://www.w3.org/WAI/WCAG21/quickref/)
|
||||
- [Phoenix LiveView Docs](https://hexdocs.pm/phoenix_live_view/)
|
||||
|
||||
---
|
||||
|
||||
**Ende des Berichts**
|
||||
|
||||
1250
docs/sidebar-requirements-v2.md
Normal file
1250
docs/sidebar-requirements-v2.md
Normal file
File diff suppressed because it is too large
Load diff
1576
docs/umsetzung-sidebar.md
Normal file
1576
docs/umsetzung-sidebar.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -9,7 +9,6 @@ defmodule MvWeb.Layouts do
|
|||
"""
|
||||
use MvWeb, :html
|
||||
use Gettext, backend: MvWeb.Gettext
|
||||
import MvWeb.Layouts.Navbar
|
||||
import MvWeb.Layouts.Sidebar
|
||||
|
||||
embed_templates "layouts/*"
|
||||
|
|
@ -44,21 +43,39 @@ defmodule MvWeb.Layouts do
|
|||
assigns = assign(assigns, :club_name, club_name)
|
||||
|
||||
~H"""
|
||||
<div class="drawer">
|
||||
<input id="main-drawer" type="checkbox" class="drawer-toggle" />
|
||||
<div class="drawer-content">
|
||||
<%= if @current_user do %>
|
||||
<.navbar current_user={@current_user} />
|
||||
<% end %>
|
||||
<main class="px-4 py-20 sm:px-6 lg:px-16">
|
||||
<div class="mx-auto space-y-4 max-full">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
<%= if @current_user do %>
|
||||
<div id="app-layout" class="drawer lg:drawer-open" data-sidebar-expanded="true" phx-hook="SidebarState">
|
||||
<input id="mobile-drawer" type="checkbox" class="drawer-toggle" />
|
||||
|
||||
<.sidebar current_user={@current_user} club_name={@club_name} />
|
||||
</div>
|
||||
<div class="drawer-content flex flex-col relative z-0">
|
||||
<!-- Mobile Header (only visible on mobile) -->
|
||||
<header class="lg:hidden sticky top-0 z-10 navbar bg-base-100 shadow-sm">
|
||||
<label for="mobile-drawer" class="btn btn-square btn-ghost" aria-label={gettext("Open navigation menu")}>
|
||||
<.icon name="hero-bars-3" class="size-6" aria-hidden="true" />
|
||||
</label>
|
||||
<span class="font-bold">{@club_name}</span>
|
||||
</header>
|
||||
|
||||
<!-- Main Content (shared between mobile and desktop) -->
|
||||
<main class="px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div class="mx-auto space-y-4 max-full">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div class="drawer-side z-40">
|
||||
<.sidebar current_user={@current_user} club_name={@club_name} mobile={false} />
|
||||
</div>
|
||||
</div>
|
||||
<% else %>
|
||||
<!-- Not logged in -->
|
||||
<main class="px-4 py-8 sm:px-6">
|
||||
<div class="mx-auto space-y-4 max-full">
|
||||
{render_slot(@inner_block)}
|
||||
</div>
|
||||
</main>
|
||||
<% end %>
|
||||
|
||||
<.flash_group flash={@flash} />
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -6,191 +6,289 @@ defmodule MvWeb.Layouts.Sidebar do
|
|||
|
||||
attr :current_user, :map, default: nil, doc: "The current user"
|
||||
attr :club_name, :string, required: true, doc: "The name of the club"
|
||||
attr :mobile, :boolean, default: false, doc: "Whether this is mobile view"
|
||||
|
||||
def sidebar(assigns) do
|
||||
~H"""
|
||||
<div class="drawer-side is-drawer-close:overflow-visible">
|
||||
<label for="mobile-drawer" aria-label={gettext("Close sidebar")} class="drawer-overlay lg:hidden focus:outline-none focus:ring-2 focus:ring-primary" tabindex="-1">
|
||||
</label>
|
||||
<aside id="main-sidebar" class="sidebar" aria-label={gettext("Main navigation")}>
|
||||
<%= sidebar_header(assigns) %>
|
||||
<%= if @current_user do %>
|
||||
<%= sidebar_menu(assigns) %>
|
||||
<% end %>
|
||||
<%= if @current_user do %>
|
||||
<.sidebar_footer current_user={@current_user} />
|
||||
<% end %>
|
||||
</aside>
|
||||
"""
|
||||
end
|
||||
|
||||
defp sidebar_header(assigns) do
|
||||
~H"""
|
||||
<div class="flex items-center gap-3 p-4 border-b border-base-300">
|
||||
<!-- Logo -->
|
||||
<img src={~p"/images/mila.svg"} alt="Mila Logo" class="size-8 shrink-0" />
|
||||
|
||||
<!-- Club Name -->
|
||||
<span class="menu-label text-lg font-bold truncate">
|
||||
{@club_name}
|
||||
</span>
|
||||
|
||||
<!-- Toggle Button (Desktop only) -->
|
||||
<%= unless @mobile do %>
|
||||
<button
|
||||
type="button"
|
||||
id="sidebar-toggle"
|
||||
class="hidden lg:flex ml-auto btn btn-ghost btn-sm btn-square"
|
||||
aria-label={gettext("Toggle sidebar")}
|
||||
aria-expanded="true"
|
||||
onclick="toggleSidebar()"
|
||||
>
|
||||
<!-- Expanded Icon (Chevron Left) -->
|
||||
<.icon
|
||||
name="hero-chevron-left"
|
||||
class="size-5 sidebar-expanded-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<!-- Collapsed Icon (Chevron Right) -->
|
||||
<.icon
|
||||
name="hero-chevron-right"
|
||||
class="size-5 sidebar-collapsed-icon"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</button>
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp sidebar_menu(assigns) do
|
||||
~H"""
|
||||
<ul class="menu flex-1 w-full p-2" role="menubar">
|
||||
<.menu_item
|
||||
href={~p"/members"}
|
||||
icon="hero-users"
|
||||
label={gettext("Members")}
|
||||
/>
|
||||
<.menu_item
|
||||
href={~p"/users"}
|
||||
icon="hero-user-circle"
|
||||
label={gettext("Users")}
|
||||
/>
|
||||
<.menu_item
|
||||
href={~p"/custom_fields"}
|
||||
icon="hero-rectangle-group"
|
||||
label={gettext("Custom Fields")}
|
||||
/>
|
||||
|
||||
<!-- Nested Menu: Contributions -->
|
||||
<.menu_group
|
||||
icon="hero-currency-dollar"
|
||||
label={gettext("Contributions")}
|
||||
>
|
||||
<.menu_subitem href="/contribution_types" label={gettext("Contribution Types")} />
|
||||
<.menu_subitem href="/contribution_settings" label={gettext("Settings")} />
|
||||
</.menu_group>
|
||||
|
||||
<.menu_item
|
||||
href="#"
|
||||
icon="hero-cog-6-tooth"
|
||||
label={gettext("Settings")}
|
||||
/>
|
||||
</ul>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :href, :string, required: true, doc: "Navigation path"
|
||||
attr :icon, :string, required: true, doc: "Heroicon name"
|
||||
attr :label, :string, required: true, doc: "Menu item label"
|
||||
|
||||
defp menu_item(assigns) do
|
||||
~H"""
|
||||
<li role="none">
|
||||
<.link
|
||||
navigate={@href}
|
||||
class="flex items-center gap-3 tooltip tooltip-right focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
data-tip={@label}
|
||||
role="menuitem"
|
||||
>
|
||||
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
|
||||
<span class="menu-label">{@label}</span>
|
||||
</.link>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :icon, :string, required: true, doc: "Heroicon name for the menu group"
|
||||
attr :label, :string, required: true, doc: "Menu group label"
|
||||
slot :inner_block, required: true, doc: "Submenu items"
|
||||
|
||||
defp menu_group(assigns) do
|
||||
~H"""
|
||||
<li role="none" class="menu-group">
|
||||
<!-- Expanded Mode: Details/Summary -->
|
||||
<details class="expanded-menu-group">
|
||||
<summary class="flex items-center gap-3 cursor-pointer focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2" role="menuitem" aria-haspopup="true">
|
||||
<.icon name={@icon} class="size-5 shrink-0" aria-hidden="true" />
|
||||
<span class="menu-label">{@label}</span>
|
||||
</summary>
|
||||
<ul role="menu" class="ml-4">
|
||||
{render_slot(@inner_block)}
|
||||
</ul>
|
||||
</details>
|
||||
|
||||
<!-- Collapsed Mode: Dropdown -->
|
||||
<div class="collapsed-menu-group dropdown dropdown-right">
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
class="flex items-center justify-center w-full p-2 rounded-lg hover:bg-base-300 tooltip tooltip-right focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
data-tip={@label}
|
||||
aria-haspopup="menu"
|
||||
aria-label={@label}
|
||||
>
|
||||
<.icon name={@icon} class="size-5" aria-hidden="true" />
|
||||
</button>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box shadow-lg z-50 min-w-48 p-2 focus:outline-none"
|
||||
role="menu"
|
||||
>
|
||||
<li class="menu-title">{@label}</li>
|
||||
{render_slot(@inner_block)}
|
||||
</ul>
|
||||
</div>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :href, :string, required: true, doc: "Navigation path for submenu item"
|
||||
attr :label, :string, required: true, doc: "Submenu item label"
|
||||
|
||||
defp menu_subitem(assigns) do
|
||||
~H"""
|
||||
<li role="none">
|
||||
<.link navigate={@href} role="menuitem" class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2">
|
||||
{@label}
|
||||
</.link>
|
||||
</li>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :current_user, :map, default: nil, doc: "The current user"
|
||||
|
||||
defp sidebar_footer(assigns) do
|
||||
~H"""
|
||||
<div class="mt-auto p-4 border-t border-base-300 space-y-4">
|
||||
<!-- Language Selector (nur expanded) -->
|
||||
<form method="post" action={~p"/set_locale"} class="expanded-only">
|
||||
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
|
||||
<select
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm w-full focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label={gettext("Select language")}
|
||||
>
|
||||
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
|
||||
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
|
||||
</select>
|
||||
</form>
|
||||
|
||||
<!-- Theme Toggle (immer sichtbar) -->
|
||||
<.theme_toggle />
|
||||
|
||||
<!-- User Menu (nur wenn current_user existiert) -->
|
||||
<%= if @current_user do %>
|
||||
<.user_menu current_user={@current_user} />
|
||||
<% end %>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
||||
defp theme_toggle(assigns) do
|
||||
~H"""
|
||||
<label class="flex items-center gap-2 cursor-pointer justify-center focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2" aria-label={gettext("Toggle dark mode")}>
|
||||
<.icon name="hero-sun" class="size-5" aria-hidden="true" />
|
||||
<input
|
||||
type="checkbox"
|
||||
value="dark"
|
||||
class="toggle toggle-sm theme-controller focus:outline-none"
|
||||
aria-label={gettext("Toggle dark mode")}
|
||||
/>
|
||||
<.icon name="hero-moon" class="size-5" aria-hidden="true" />
|
||||
</label>
|
||||
"""
|
||||
end
|
||||
|
||||
attr :current_user, :map, default: nil, doc: "The current user"
|
||||
|
||||
defp user_menu(assigns) do
|
||||
# Defensive check: ensure current_user and email exist
|
||||
# current_user might be a struct, so we need to handle both maps and structs
|
||||
# email might be an Ash.CiString, so we use to_string/1 (not String.to_string/1)
|
||||
email = case assigns.current_user do
|
||||
nil -> ""
|
||||
%{email: email_val} when not is_nil(email_val) -> to_string(email_val)
|
||||
_ -> ""
|
||||
end
|
||||
|
||||
first_letter = if email != "" do
|
||||
String.first(email) |> String.upcase()
|
||||
else
|
||||
"?"
|
||||
end
|
||||
|
||||
user_id = case assigns.current_user do
|
||||
nil -> nil
|
||||
%{id: id_val} when not is_nil(id_val) -> id_val
|
||||
_ -> nil
|
||||
end
|
||||
|
||||
# Store computed values in assigns to avoid HEEx warnings
|
||||
assigns = assign(assigns, :email, email)
|
||||
assigns = assign(assigns, :first_letter, first_letter)
|
||||
assigns = assign(assigns, :user_id, user_id)
|
||||
|
||||
~H"""
|
||||
<div class="dropdown dropdown-top dropdown-right w-full">
|
||||
<button
|
||||
type="button"
|
||||
onclick="document.getElementById('main-drawer').checked = false"
|
||||
aria-label={gettext("Close sidebar")}
|
||||
class="drawer-overlay focus:outline-none focus:ring-2 focus:ring-primary"
|
||||
tabindex="-1"
|
||||
tabindex="0"
|
||||
class="btn btn-ghost w-full justify-start gap-3 px-4 h-12 min-h-12 focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label={gettext("User menu")}
|
||||
aria-haspopup="menu"
|
||||
>
|
||||
</button>
|
||||
<nav
|
||||
id="main-sidebar"
|
||||
aria-label={gettext("Main navigation")}
|
||||
class="flex flex-col items-start min-h-full bg-base-200 is-drawer-close:w-14 is-drawer-open:w-64"
|
||||
>
|
||||
<ul class="w-64 menu" role="menubar">
|
||||
<li>
|
||||
<h1 class="mb-2 text-lg font-bold menu-title is-drawer-close:hidden">{@club_name}</h1>
|
||||
</li>
|
||||
<%= if @current_user do %>
|
||||
<li role="none">
|
||||
<.link
|
||||
navigate="/members"
|
||||
class={[
|
||||
"is-drawer-close:tooltip is-drawer-close:tooltip-right focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
]}
|
||||
data-tip={gettext("Members")}
|
||||
role="menuitem"
|
||||
>
|
||||
<.icon name="hero-users" class="size-5" aria-hidden="true" />
|
||||
<span class="is-drawer-close:hidden">{gettext("Members")}</span>
|
||||
</.link>
|
||||
</li>
|
||||
<li role="none">
|
||||
<.link
|
||||
navigate="/users"
|
||||
class={[
|
||||
"is-drawer-close:tooltip is-drawer-close:tooltip-right focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
]}
|
||||
data-tip={gettext("Users")}
|
||||
role="menuitem"
|
||||
>
|
||||
<.icon name="hero-user-circle" class="size-5" aria-hidden="true" />
|
||||
<span class="is-drawer-close:hidden">{gettext("Users")}</span>
|
||||
</.link>
|
||||
</li>
|
||||
<li class="is-drawer-close:hidden" role="none">
|
||||
<h2 class="flex items-center gap-2 menu-title">
|
||||
<.icon name="hero-currency-dollar" class="size-5" aria-hidden="true" />
|
||||
{gettext("Contributions")}
|
||||
</h2>
|
||||
<ul role="menu">
|
||||
<li class="is-drawer-close:hidden" role="none">
|
||||
<.link
|
||||
navigate="/contribution_types"
|
||||
role="menuitem"
|
||||
class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
>
|
||||
{gettext("Plans")}
|
||||
</.link>
|
||||
</li>
|
||||
<li class="is-drawer-close:hidden" role="none">
|
||||
<.link
|
||||
navigate="/contribution_settings"
|
||||
role="menuitem"
|
||||
class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
>
|
||||
{gettext("Settings")}
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li role="none">
|
||||
<.link
|
||||
navigate="/settings"
|
||||
class={[
|
||||
"is-drawer-close:tooltip is-drawer-close:tooltip-right focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
]}
|
||||
data-tip={gettext("Settings")}
|
||||
role="menuitem"
|
||||
>
|
||||
<.icon name="hero-cog-6-tooth" class="size-5" aria-hidden="true" />
|
||||
<span class="is-drawer-close:hidden">{gettext("Settings")}</span>
|
||||
</.link>
|
||||
</li>
|
||||
<% end %>
|
||||
</ul>
|
||||
<%= if @current_user do %>
|
||||
<div class="flex flex-col gap-4 p-4 mt-auto w-full is-drawer-close:items-center">
|
||||
<form method="post" action="/set_locale" class="w-full">
|
||||
<input type="hidden" name="_csrf_token" value={get_csrf_token()} />
|
||||
<label class="sr-only" for="locale-select-sidebar">{gettext("Select language")}</label>
|
||||
<select
|
||||
id="locale-select-sidebar"
|
||||
name="locale"
|
||||
onchange="this.form.submit()"
|
||||
class="select select-sm w-full is-drawer-close:w-auto focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
aria-label={gettext("Select language")}
|
||||
>
|
||||
<option value="de" selected={Gettext.get_locale() == "de"}>Deutsch</option>
|
||||
<option value="en" selected={Gettext.get_locale() == "en"}>English</option>
|
||||
</select>
|
||||
</form>
|
||||
<!-- Daisy UI Theme Toggle for dark and light mode-->
|
||||
<label class="flex gap-2 cursor-pointer is-drawer-close:justify-center focus-within:outline-none focus-within:ring-2 focus-within:ring-primary focus-within:ring-offset-2" aria-label={gettext("Toggle dark mode")}>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<path d="M12 1v2M12 21v2M4.2 4.2l1.4 1.4M18.4 18.4l1.4 1.4M1 12h2M21 12h2M4.2 19.8l1.4-1.4M18.4 5.6l1.4-1.4" />
|
||||
</svg>
|
||||
<input
|
||||
type="checkbox"
|
||||
value="dark"
|
||||
class="toggle theme-controller focus:outline-none"
|
||||
aria-label={gettext("Toggle dark mode")}
|
||||
/>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path>
|
||||
</svg>
|
||||
</label>
|
||||
<div class="dropdown dropdown-top is-drawer-close:dropdown-end">
|
||||
<button
|
||||
type="button"
|
||||
tabindex="0"
|
||||
role="button"
|
||||
aria-label={gettext("User menu")}
|
||||
aria-haspopup="true"
|
||||
aria-expanded="false"
|
||||
class="btn btn-ghost btn-circle avatar avatar-placeholder focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
>
|
||||
<div class="w-12 rounded-full bg-neutral text-neutral-content">
|
||||
<span aria-hidden="true">AA</span>
|
||||
</div>
|
||||
</button>
|
||||
<ul
|
||||
role="menu"
|
||||
tabindex="0"
|
||||
class="p-2 mt-3 shadow menu menu-sm dropdown-content bg-base-100 rounded-box z-1 w-52 focus:outline-none"
|
||||
>
|
||||
<li role="none">
|
||||
<.link
|
||||
navigate={~p"/users/#{@current_user.id}"}
|
||||
role="menuitem"
|
||||
class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
>
|
||||
{gettext("Profil")}
|
||||
</.link>
|
||||
</li>
|
||||
<li role="none">
|
||||
<.link
|
||||
href={~p"/sign-out"}
|
||||
role="menuitem"
|
||||
class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2"
|
||||
>
|
||||
{gettext("Logout")}
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<!-- Avatar: Zentriert, erste Buchstabe -->
|
||||
<div class="avatar placeholder shrink-0">
|
||||
<div class="w-8 h-8 rounded-full bg-neutral text-neutral-content flex items-center justify-center">
|
||||
<span class="text-sm font-semibold leading-none flex items-center justify-center" style="margin-top: 1px;">
|
||||
{@first_letter}
|
||||
</span>
|
||||
</div>
|
||||
<% end %>
|
||||
</nav>
|
||||
</div>
|
||||
<span class="menu-label truncate flex-1 text-left">{@email}</span>
|
||||
</button>
|
||||
<ul
|
||||
tabindex="0"
|
||||
class="dropdown-content menu bg-base-100 rounded-box shadow-lg w-52 p-2 focus:outline-none absolute z-[100]"
|
||||
role="menu"
|
||||
>
|
||||
<li role="none">
|
||||
<%= if @user_id do %>
|
||||
<.link navigate={~p"/users/#{@user_id}"} role="menuitem" class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2">
|
||||
{gettext("Profile")}
|
||||
</.link>
|
||||
<% else %>
|
||||
<span class="opacity-50">{gettext("Profile")}</span>
|
||||
<% end %>
|
||||
</li>
|
||||
<li role="none">
|
||||
<.link href={~p"/sign-out"} role="menuitem" class="focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2">
|
||||
{gettext("Logout")}
|
||||
</.link>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
end
|
||||
|
|
|
|||
5
priv/static/images/mila.svg
Normal file
5
priv/static/images/mila.svg
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100" width="100" height="100">
|
||||
<rect width="100" height="100" rx="12" fill="#4f46e5"/>
|
||||
<text x="50" y="65" font-family="Arial, sans-serif" font-size="48" font-weight="bold" fill="white" text-anchor="middle">M</text>
|
||||
</svg>
|
||||
|
||||
|
After Width: | Height: | Size: 285 B |
850
test/mv_web/components/layouts/sidebar_test.exs
Normal file
850
test/mv_web/components/layouts/sidebar_test.exs
Normal file
|
|
@ -0,0 +1,850 @@
|
|||
defmodule MvWeb.Layouts.SidebarTest do
|
||||
@moduledoc """
|
||||
Unit tests for the Sidebar component.
|
||||
|
||||
Tests cover:
|
||||
- Basic rendering and structure
|
||||
- Props handling (current_user, club_name, mobile)
|
||||
- Menu structure (flat and nested items)
|
||||
- Footer/Profile section
|
||||
- Accessibility attributes (ARIA labels, roles)
|
||||
- CSS classes (DaisyUI conformance)
|
||||
- Icon rendering
|
||||
- Conditional visibility
|
||||
"""
|
||||
use MvWeb.ConnCase, async: true
|
||||
|
||||
import Phoenix.LiveViewTest
|
||||
import MvWeb.Layouts.Sidebar
|
||||
|
||||
# =============================================================================
|
||||
# Helper Functions
|
||||
# =============================================================================
|
||||
|
||||
# Returns assigns for an authenticated user with all required attributes.
|
||||
defp authenticated_assigns(mobile \\ false) do
|
||||
%{
|
||||
current_user: %{id: "user-123", email: "test@example.com"},
|
||||
club_name: "Test Club",
|
||||
mobile: mobile
|
||||
}
|
||||
end
|
||||
|
||||
# Returns assigns for a guest user (not authenticated).
|
||||
defp guest_assigns(mobile \\ false) do
|
||||
%{
|
||||
current_user: nil,
|
||||
club_name: "Test Club",
|
||||
mobile: mobile
|
||||
}
|
||||
end
|
||||
|
||||
# Renders the sidebar component with the given assigns.
|
||||
defp render_sidebar(assigns) do
|
||||
render_component(&sidebar/1, assigns)
|
||||
end
|
||||
|
||||
# Checks if the HTML contains a specific CSS class.
|
||||
defp has_class?(html, class) do
|
||||
html =~ class
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Group 1: Basic Rendering (T1.1-T1.3)
|
||||
# =============================================================================
|
||||
|
||||
describe "basic rendering" do
|
||||
test "T1.1: renders sidebar with main navigation structure" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check for navigation element with correct ID
|
||||
assert html =~ ~s(id="main-sidebar")
|
||||
assert html =~ ~s(aria-label="Main navigation")
|
||||
|
||||
# Check for sidebar class
|
||||
assert has_class?(html, "sidebar")
|
||||
end
|
||||
|
||||
test "T1.2: renders logo correctly" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check for logo image
|
||||
assert html =~ ~s(src="/images/mila.svg")
|
||||
assert html =~ ~s(alt="Mila Logo")
|
||||
|
||||
# Check logo has correct size class
|
||||
assert has_class?(html, "size-8")
|
||||
end
|
||||
|
||||
test "T1.3: renders toggle button with correct attributes (desktop only)" do
|
||||
html = render_sidebar(authenticated_assigns(false))
|
||||
|
||||
# Check for toggle button
|
||||
assert html =~ ~s(id="sidebar-toggle")
|
||||
assert html =~ "onclick="
|
||||
|
||||
# Check for DaisyUI button classes
|
||||
assert has_class?(html, "btn")
|
||||
assert has_class?(html, "btn-ghost")
|
||||
assert has_class?(html, "btn-sm")
|
||||
assert has_class?(html, "btn-square")
|
||||
|
||||
# Check for both toggle icons (expanded and collapsed)
|
||||
assert has_class?(html, "sidebar-expanded-icon")
|
||||
assert has_class?(html, "sidebar-collapsed-icon")
|
||||
end
|
||||
|
||||
test "T1.4: does not render toggle button on mobile" do
|
||||
html = render_sidebar(authenticated_assigns(true))
|
||||
|
||||
# Toggle button should not be rendered on mobile
|
||||
refute html =~ ~s(id="sidebar-toggle")
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Group 2: Props Handling (T2.1-T2.3)
|
||||
# =============================================================================
|
||||
|
||||
describe "props handling" do
|
||||
test "T2.1: displays club name when provided" do
|
||||
assigns = %{
|
||||
current_user: %{id: "user-1", email: "test@example.com"},
|
||||
club_name: "My Awesome Club",
|
||||
mobile: false
|
||||
}
|
||||
|
||||
html = render_sidebar(assigns)
|
||||
|
||||
assert html =~ "My Awesome Club"
|
||||
end
|
||||
|
||||
test "T2.2: does not render menu items when current_user is nil" do
|
||||
html = render_sidebar(guest_assigns())
|
||||
|
||||
# Navigation links should not be rendered
|
||||
refute html =~ ~s(href="/members")
|
||||
refute html =~ ~s(href="/users")
|
||||
refute html =~ ~s(href="/settings")
|
||||
refute html =~ ~s(href="/contribution_types")
|
||||
|
||||
# Footer section should not be rendered
|
||||
refute html =~ "locale-select"
|
||||
refute html =~ "theme-controller"
|
||||
end
|
||||
|
||||
test "T2.3: renders menu items when current_user is present" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check for Members link
|
||||
assert html =~ ~s(href="/members")
|
||||
|
||||
# Check for Users link
|
||||
assert html =~ ~s(href="/users")
|
||||
|
||||
# Check for Custom Fields link
|
||||
assert html =~ ~s(href="/custom_fields")
|
||||
|
||||
# Check for Contributions section
|
||||
assert html =~ ~s(href="/contribution_types")
|
||||
assert html =~ ~s(href="/contribution_settings")
|
||||
|
||||
# Check for Settings link (placeholder)
|
||||
assert html =~ ~s(href="#")
|
||||
end
|
||||
|
||||
test "T2.4: renders sidebar with main-sidebar ID" do
|
||||
html = render_sidebar(authenticated_assigns(true))
|
||||
|
||||
assert html =~ ~s(id="main-sidebar")
|
||||
end
|
||||
|
||||
test "T2.5: renders sidebar with main-sidebar ID on desktop" do
|
||||
html = render_sidebar(authenticated_assigns(false))
|
||||
|
||||
assert html =~ ~s(id="main-sidebar")
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Group 3: Menu Structure (T3.1-T3.3)
|
||||
# =============================================================================
|
||||
|
||||
describe "menu structure" do
|
||||
test "T3.1: renders flat menu items with icons and labels" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check for Members link with icon
|
||||
assert html =~ ~s(href="/members")
|
||||
assert html =~ "hero-users"
|
||||
|
||||
# Check for Users link with icon
|
||||
assert html =~ ~s(href="/users")
|
||||
assert html =~ "hero-user-circle"
|
||||
|
||||
# Check for Custom Fields link with icon
|
||||
assert html =~ ~s(href="/custom_fields")
|
||||
assert html =~ "hero-rectangle-group"
|
||||
|
||||
# Check for Settings link with icon
|
||||
assert html =~ ~s(href="#")
|
||||
assert html =~ "hero-cog-6-tooth"
|
||||
|
||||
# Check for tooltips (data-tip attribute)
|
||||
assert html =~ "data-tip="
|
||||
end
|
||||
|
||||
test "T3.2: renders nested menu with details element for expanded state" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check for Contributions section structure with details
|
||||
assert html =~ "<details"
|
||||
assert has_class?(html, "expanded-menu-group")
|
||||
|
||||
# Check for contribution links
|
||||
assert html =~ ~s(href="/contribution_types")
|
||||
assert html =~ ~s(href="/contribution_settings")
|
||||
end
|
||||
|
||||
test "T3.3: renders nested menu with dropdown for collapsed state" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check for collapsed dropdown container
|
||||
assert has_class?(html, "collapsed-menu-group")
|
||||
assert has_class?(html, "dropdown")
|
||||
assert has_class?(html, "dropdown-right")
|
||||
|
||||
# Check for dropdown-content
|
||||
assert has_class?(html, "dropdown-content")
|
||||
|
||||
# Check for icon button
|
||||
assert html =~ "hero-currency-dollar"
|
||||
assert html =~ ~s(aria-haspopup="menu")
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Group 4: Footer/Profile Section (T4.1-T4.3)
|
||||
# =============================================================================
|
||||
|
||||
describe "footer/profile section" do
|
||||
test "T4.1: renders footer section when user is authenticated" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check for footer container with mt-auto
|
||||
assert has_class?(html, "mt-auto")
|
||||
|
||||
# Check for language selector form
|
||||
assert html =~ ~s(action="/set_locale")
|
||||
|
||||
# Check for theme toggle
|
||||
assert has_class?(html, "theme-controller")
|
||||
|
||||
# Check for user menu/avatar
|
||||
assert has_class?(html, "avatar")
|
||||
end
|
||||
|
||||
test "T4.2: renders language selector with form and options" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check for form with correct action
|
||||
assert html =~ ~s(action="/set_locale")
|
||||
assert html =~ ~s(method="post")
|
||||
|
||||
# Check for CSRF token
|
||||
assert html =~ "_csrf_token"
|
||||
|
||||
# Check for select element
|
||||
assert html =~ ~s(name="locale")
|
||||
|
||||
# Check for language options
|
||||
assert html =~ ~s(value="de")
|
||||
assert html =~ "Deutsch"
|
||||
assert html =~ ~s(value="en")
|
||||
assert html =~ "English"
|
||||
|
||||
# Check expanded-only class
|
||||
assert has_class?(html, "expanded-only")
|
||||
end
|
||||
|
||||
test "T4.3: renders user dropdown with profile and logout links" do
|
||||
assigns = %{
|
||||
current_user: %{id: "user-456", email: "test@example.com"},
|
||||
club_name: "Test Club",
|
||||
mobile: false
|
||||
}
|
||||
|
||||
html = render_sidebar(assigns)
|
||||
|
||||
# Check for dropdown container
|
||||
assert has_class?(html, "dropdown")
|
||||
assert has_class?(html, "dropdown-top")
|
||||
|
||||
# Check for avatar button
|
||||
assert html =~ ~s(aria-haspopup="menu")
|
||||
|
||||
# Check for profile link (with user ID)
|
||||
assert html =~ ~s(href="/users/user-456")
|
||||
|
||||
# Check for logout link
|
||||
assert html =~ ~s(href="/sign-out")
|
||||
|
||||
# Check for DaisyUI dropdown classes
|
||||
assert has_class?(html, "dropdown-content")
|
||||
end
|
||||
|
||||
test "T4.4: renders user avatar with placeholder" do
|
||||
assigns = %{
|
||||
current_user: %{id: "user-789", email: "alice@example.com"},
|
||||
club_name: "Test Club",
|
||||
mobile: false
|
||||
}
|
||||
|
||||
html = render_sidebar(assigns)
|
||||
|
||||
# Should have avatar placeholder classes
|
||||
assert has_class?(html, "avatar")
|
||||
assert has_class?(html, "placeholder")
|
||||
assert has_class?(html, "bg-neutral")
|
||||
assert has_class?(html, "text-neutral-content")
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Group 5: Accessibility Attributes (T5.1-T5.5)
|
||||
# =============================================================================
|
||||
|
||||
describe "accessibility attributes" do
|
||||
test "T5.1: navigation has correct ARIA label" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
assert html =~ ~s(aria-label="Main navigation")
|
||||
end
|
||||
|
||||
test "T5.2: toggle button has correct ARIA attributes" do
|
||||
html = render_sidebar(authenticated_assigns(false))
|
||||
|
||||
# Toggle button
|
||||
assert html =~ ~s(aria-label="Toggle sidebar")
|
||||
assert html =~ ~s(aria-controls="main-sidebar")
|
||||
assert html =~ ~s(aria-expanded="true")
|
||||
end
|
||||
|
||||
test "T5.3: menu has correct roles" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Main menu should have menubar role
|
||||
assert html =~ ~s(role="menubar")
|
||||
|
||||
# List items should have role="none"
|
||||
assert html =~ ~s(role="none")
|
||||
|
||||
# Links should have role="menuitem"
|
||||
assert html =~ ~s(role="menuitem")
|
||||
end
|
||||
|
||||
test "T5.4: nested menu has correct ARIA attributes" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Details summary should have haspopup
|
||||
assert html =~ ~s(aria-haspopup="true")
|
||||
|
||||
# Dropdown button should have haspopup
|
||||
assert html =~ ~s(aria-haspopup="menu")
|
||||
|
||||
# Nested menus should have role="menu"
|
||||
assert html =~ ~s(role="menu")
|
||||
end
|
||||
|
||||
test "T5.5: icons are hidden from screen readers" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Icons should have aria-hidden="true"
|
||||
assert html =~ ~s(aria-hidden="true")
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Group 6: CSS Classes - DaisyUI Conformance (T6.1-T6.4)
|
||||
# =============================================================================
|
||||
|
||||
describe "CSS classes - DaisyUI conformance" do
|
||||
test "T6.1: uses correct DaisyUI menu classes" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# menu class on ul
|
||||
assert has_class?(html, "menu")
|
||||
end
|
||||
|
||||
test "T6.2: uses correct DaisyUI button classes" do
|
||||
html = render_sidebar(authenticated_assigns(false))
|
||||
|
||||
# Button classes
|
||||
assert has_class?(html, "btn")
|
||||
assert has_class?(html, "btn-ghost")
|
||||
assert has_class?(html, "btn-sm")
|
||||
assert has_class?(html, "btn-square")
|
||||
end
|
||||
|
||||
test "T6.3: uses correct tooltip classes" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Tooltip classes for menu items
|
||||
assert has_class?(html, "tooltip")
|
||||
assert has_class?(html, "tooltip-right")
|
||||
end
|
||||
|
||||
test "T6.4: uses correct dropdown classes" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Dropdown classes
|
||||
assert has_class?(html, "dropdown")
|
||||
assert has_class?(html, "dropdown-right")
|
||||
assert has_class?(html, "dropdown-top")
|
||||
assert has_class?(html, "dropdown-content")
|
||||
assert has_class?(html, "rounded-box")
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Group 7: Icon Rendering (T7.1-T7.2)
|
||||
# =============================================================================
|
||||
|
||||
describe "icon rendering" do
|
||||
test "T7.1: renders hero icons for menu items" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check for hero icons
|
||||
assert html =~ "hero-users"
|
||||
assert html =~ "hero-user-circle"
|
||||
assert html =~ "hero-rectangle-group"
|
||||
assert html =~ "hero-currency-dollar"
|
||||
assert html =~ "hero-cog-6-tooth"
|
||||
assert html =~ "hero-chevron-left"
|
||||
assert html =~ "hero-chevron-right"
|
||||
|
||||
# Icons should have aria-hidden
|
||||
assert html =~ ~s(aria-hidden="true")
|
||||
end
|
||||
|
||||
test "T7.2: renders icons for theme toggle" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Theme toggle icons (sun and moon)
|
||||
assert html =~ "hero-sun"
|
||||
assert html =~ "hero-moon"
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Group 8: State-dependent classes (T8.1-T8.3)
|
||||
# =============================================================================
|
||||
|
||||
describe "state-dependent classes" do
|
||||
test "T8.1: expanded-only elements have correct CSS class" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Language form should be expanded-only
|
||||
assert has_class?(html, "expanded-only")
|
||||
end
|
||||
|
||||
test "T8.2: menu-label class is used for text that hides when collapsed" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Menu labels that hide in collapsed state
|
||||
assert has_class?(html, "menu-label")
|
||||
end
|
||||
|
||||
test "T8.3: toggle button has state icons" do
|
||||
html = render_sidebar(authenticated_assigns(false))
|
||||
|
||||
# Expanded icon
|
||||
assert has_class?(html, "sidebar-expanded-icon")
|
||||
|
||||
# Collapsed icon
|
||||
assert has_class?(html, "sidebar-collapsed-icon")
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Additional Edge Cases and Validation
|
||||
# =============================================================================
|
||||
|
||||
describe "edge cases" do
|
||||
test "handles user with minimal attributes" do
|
||||
assigns = %{
|
||||
current_user: %{id: "minimal-user", email: "min@test.com"},
|
||||
club_name: "Minimal Club",
|
||||
mobile: false
|
||||
}
|
||||
|
||||
html = render_sidebar(assigns)
|
||||
|
||||
# Should render without error
|
||||
assert html =~ "Minimal Club"
|
||||
assert html =~ ~s(href="/users/minimal-user")
|
||||
end
|
||||
|
||||
test "handles empty club name" do
|
||||
assigns = %{
|
||||
current_user: %{id: "user-1", email: "test@test.com"},
|
||||
club_name: "",
|
||||
mobile: false
|
||||
}
|
||||
|
||||
html = render_sidebar(assigns)
|
||||
|
||||
# Should render without error
|
||||
assert html =~ ~s(id="main-sidebar")
|
||||
end
|
||||
|
||||
test "sidebar structure is complete with all sections" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Header section
|
||||
assert html =~ "Mila Logo"
|
||||
|
||||
# Navigation section
|
||||
assert html =~ ~s(role="menubar")
|
||||
|
||||
# Footer section
|
||||
assert html =~ "theme-controller"
|
||||
|
||||
# All expected links
|
||||
expected_links = [
|
||||
"/members",
|
||||
"/users",
|
||||
"/custom_fields",
|
||||
"/contribution_types",
|
||||
"/contribution_settings",
|
||||
"/sign-out"
|
||||
]
|
||||
|
||||
for link <- expected_links do
|
||||
assert html =~ ~s(href="#{link}"), "Missing link: #{link}"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Component Tests - sidebar_header/1
|
||||
# =============================================================================
|
||||
|
||||
describe "sidebar_header/1" do
|
||||
test "renders logo with correct size" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Logo is size-8 (32px)
|
||||
assert html =~ ~s(src="/images/mila.svg")
|
||||
assert html =~ ~s(alt="Mila Logo")
|
||||
assert has_class?(html, "size-8")
|
||||
|
||||
# Logo is always visible (no conditional classes)
|
||||
assert html =~ ~s(<img)
|
||||
end
|
||||
|
||||
test "renders club name" do
|
||||
assigns = %{
|
||||
current_user: %{id: "user-1", email: "test@example.com"},
|
||||
club_name: "My Test Club",
|
||||
mobile: false
|
||||
}
|
||||
|
||||
html = render_sidebar(assigns)
|
||||
|
||||
# Club name is present
|
||||
assert html =~ "My Test Club"
|
||||
assert has_class?(html, "menu-label")
|
||||
end
|
||||
|
||||
test "renders toggle button for desktop" do
|
||||
html = render_sidebar(authenticated_assigns(false))
|
||||
|
||||
# Toggle button has both icons
|
||||
assert has_class?(html, "sidebar-expanded-icon")
|
||||
assert has_class?(html, "sidebar-collapsed-icon")
|
||||
|
||||
# Toggle button is not mobile (hidden on mobile)
|
||||
assert html =~ ~s(id="sidebar-toggle")
|
||||
assert html =~ ~s(aria-label="Toggle sidebar")
|
||||
assert html =~ ~s(aria-expanded="true")
|
||||
end
|
||||
|
||||
test "does not render toggle button on mobile" do
|
||||
html = render_sidebar(authenticated_assigns(true))
|
||||
|
||||
# Toggle button should not be rendered on mobile
|
||||
refute html =~ ~s(id="sidebar-toggle")
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Component Tests - menu_item/1
|
||||
# =============================================================================
|
||||
|
||||
describe "menu_item/1" do
|
||||
test "renders icon and label" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Icon is visible
|
||||
assert html =~ "hero-users"
|
||||
assert html =~ ~s(aria-hidden="true")
|
||||
|
||||
# Label is present
|
||||
assert html =~ "Members"
|
||||
assert has_class?(html, "menu-label")
|
||||
end
|
||||
|
||||
test "has tooltip with correct text" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# data-tip attribute set
|
||||
assert html =~ ~s(data-tip="Members")
|
||||
assert has_class?(html, "tooltip")
|
||||
assert has_class?(html, "tooltip-right")
|
||||
end
|
||||
|
||||
test "has correct link" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# navigate attribute correct (rendered as href)
|
||||
assert html =~ ~s(href="/members")
|
||||
assert html =~ ~s(role="menuitem")
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Component Tests - menu_group/1
|
||||
# =============================================================================
|
||||
|
||||
describe "menu_group/1" do
|
||||
test "renders expanded menu group" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# details/summary present
|
||||
assert html =~ "<details"
|
||||
assert html =~ "<summary"
|
||||
assert has_class?(html, "expanded-menu-group")
|
||||
end
|
||||
|
||||
test "renders collapsed menu group" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# dropdown present
|
||||
assert has_class?(html, "collapsed-menu-group")
|
||||
assert has_class?(html, "dropdown")
|
||||
assert has_class?(html, "dropdown-right")
|
||||
end
|
||||
|
||||
test "renders submenu items" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Inner_block items rendered
|
||||
assert html =~ ~s(href="/contribution_types")
|
||||
assert html =~ ~s(href="/contribution_settings")
|
||||
assert html =~ ~s(role="menu")
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Component Tests - sidebar_footer/1
|
||||
# =============================================================================
|
||||
|
||||
describe "sidebar_footer/1" do
|
||||
test "renders at bottom of sidebar" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# mt-auto present
|
||||
assert has_class?(html, "mt-auto")
|
||||
end
|
||||
|
||||
test "renders theme toggle" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Toggle is always visible
|
||||
assert has_class?(html, "theme-controller")
|
||||
assert html =~ "hero-sun"
|
||||
assert html =~ "hero-moon"
|
||||
end
|
||||
|
||||
test "renders language selector in expanded only" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# expanded-only class
|
||||
assert has_class?(html, "expanded-only")
|
||||
assert html =~ ~s(action="/set_locale")
|
||||
end
|
||||
|
||||
test "renders user menu with avatar" do
|
||||
assigns = %{
|
||||
current_user: %{id: "user-123", email: "alice@example.com"},
|
||||
club_name: "Test Club",
|
||||
mobile: false
|
||||
}
|
||||
|
||||
html = render_sidebar(assigns)
|
||||
|
||||
# Avatar present
|
||||
assert has_class?(html, "avatar")
|
||||
assert has_class?(html, "placeholder")
|
||||
|
||||
# First letter correct (A for alice@example.com)
|
||||
assert html =~ "A"
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Integration Tests - Sidebar State Management
|
||||
# =============================================================================
|
||||
|
||||
describe "sidebar state management" do
|
||||
test "sidebar starts expanded by default" do
|
||||
# The data-sidebar-expanded attribute is set in layouts.ex, not in sidebar.ex
|
||||
# This test verifies the sidebar structure supports the expanded state
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Sidebar has classes that support expanded state
|
||||
assert has_class?(html, "menu-label")
|
||||
assert has_class?(html, "expanded-only")
|
||||
end
|
||||
|
||||
test "toggle button has correct onclick handler" do
|
||||
html = render_sidebar(authenticated_assigns(false))
|
||||
|
||||
# Toggle button has onclick handler
|
||||
assert html =~ ~s(onclick="toggleSidebar()")
|
||||
end
|
||||
|
||||
test "no duplicate IDs in layout" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check that main-sidebar ID appears only once
|
||||
id_count = html |> String.split(~s(id="main-sidebar")) |> length() |> Kernel.-(1)
|
||||
assert id_count == 1, "main-sidebar ID should appear exactly once"
|
||||
|
||||
# Check that sidebar-toggle ID appears only once (if present)
|
||||
if html =~ ~s(id="sidebar-toggle") do
|
||||
toggle_count = html |> String.split(~s(id="sidebar-toggle")) |> length() |> Kernel.-(1)
|
||||
assert toggle_count == 1, "sidebar-toggle ID should appear exactly once"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Integration Tests - Mobile Drawer
|
||||
# =============================================================================
|
||||
|
||||
describe "mobile drawer" do
|
||||
test "mobile header renders on small screens" do
|
||||
# Mobile header is in layouts.ex, not sidebar.ex
|
||||
# This test verifies sidebar works correctly with mobile flag
|
||||
html = render_sidebar(authenticated_assigns(true))
|
||||
|
||||
# Sidebar should render without toggle button on mobile
|
||||
refute html =~ ~s(id="sidebar-toggle")
|
||||
end
|
||||
|
||||
test "drawer overlay is present" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Drawer overlay label for mobile
|
||||
assert html =~ ~s(for="mobile-drawer")
|
||||
assert html =~ ~s(class="drawer-overlay")
|
||||
assert html =~ ~s(lg:hidden)
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Accessibility Tests - Extended
|
||||
# =============================================================================
|
||||
|
||||
describe "accessibility - extended" do
|
||||
test "sidebar has aria-label" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
assert html =~ ~s(aria-label="Main navigation")
|
||||
end
|
||||
|
||||
test "toggle button has aria-label and aria-expanded" do
|
||||
html = render_sidebar(authenticated_assigns(false))
|
||||
|
||||
# aria-label present
|
||||
assert html =~ ~s(aria-label="Toggle sidebar")
|
||||
|
||||
# aria-expanded="true" or "false"
|
||||
assert html =~ ~s(aria-expanded="true") || html =~ ~s(aria-expanded="false")
|
||||
end
|
||||
|
||||
test "menu items have role attributes" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# role="menubar", "menuitem", "menu"
|
||||
assert html =~ ~s(role="menubar")
|
||||
assert html =~ ~s(role="menuitem")
|
||||
assert html =~ ~s(role="menu")
|
||||
end
|
||||
|
||||
test "icons have aria-hidden" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Decorative icons: aria-hidden="true"
|
||||
# Count occurrences to ensure multiple icons have it
|
||||
assert html =~ ~s(aria-hidden="true")
|
||||
end
|
||||
|
||||
test "user menu has aria-haspopup" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# aria-haspopup="menu"
|
||||
assert html =~ ~s(aria-haspopup="menu")
|
||||
end
|
||||
end
|
||||
|
||||
# =============================================================================
|
||||
# Regression Tests
|
||||
# =============================================================================
|
||||
|
||||
describe "regression tests" do
|
||||
test "no duplicate profile links" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Only one Profile link in DOM
|
||||
profile_count = html |> String.split(~s[href="/users/"]) |> length() |> Kernel.-(1)
|
||||
assert profile_count <= 1, "Should have at most one profile link"
|
||||
end
|
||||
|
||||
test "nested menu has only one hover effect" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Check that menu-group has proper structure
|
||||
# Both expanded and collapsed versions should be present
|
||||
assert has_class?(html, "expanded-menu-group")
|
||||
assert has_class?(html, "collapsed-menu-group")
|
||||
|
||||
# Details element should not have duplicate hover classes
|
||||
# (CSS handles this, but we verify structure)
|
||||
assert html =~ "<details"
|
||||
end
|
||||
|
||||
test "tooltips only visible when collapsed" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Tooltip classes are present (CSS handles visibility)
|
||||
assert has_class?(html, "tooltip")
|
||||
assert has_class?(html, "tooltip-right")
|
||||
assert html =~ "data-tip="
|
||||
end
|
||||
|
||||
test "user menu dropdown has correct structure" do
|
||||
html = render_sidebar(authenticated_assigns())
|
||||
|
||||
# Dropdown should have proper classes
|
||||
assert has_class?(html, "dropdown")
|
||||
assert has_class?(html, "dropdown-top")
|
||||
assert has_class?(html, "dropdown-content")
|
||||
|
||||
# Should have both Profile and Logout links
|
||||
assert html =~ ~s(href="/sign-out")
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Add a link
Reference in a new issue