feat: implement standard-compliant sidebar with comprehensive tests
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:
Simon 2025-12-18 16:33:44 +01:00
parent b0097ab99d
commit 16ca4efc03
Signed by: simon
GPG key ID: 40E7A58C4AA1EDB2
10 changed files with 5439 additions and 194 deletions

View file

@ -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