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
295 lines
9.8 KiB
Elixir
295 lines
9.8 KiB
Elixir
defmodule MvWeb.Layouts.Sidebar do
|
|
@moduledoc """
|
|
Sidebar navigation component used in the drawer layout
|
|
"""
|
|
use MvWeb, :html
|
|
|
|
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"""
|
|
<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"
|
|
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"
|
|
>
|
|
<!-- 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>
|
|
</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
|
|
end
|