fix: unify responsive application header

This commit is contained in:
SimpleTest 2026-07-19 20:53:26 +03:00
parent 1dda417a94
commit 768d63ad5f
4 changed files with 249 additions and 108 deletions

View File

@ -45,6 +45,7 @@ test("public navigation does not overflow narrow or wide viewports", async ({ br
for (const viewport of [ for (const viewport of [
{ width: 360, height: 800 }, { width: 360, height: 800 },
{ width: 768, height: 1024 }, { width: 768, height: 1024 },
{ width: 1030, height: 900 },
{ width: 1440, height: 900 }, { width: 1440, height: 900 },
]) { ]) {
const context = await newIsolatedContext(browser); const context = await newIsolatedContext(browser);
@ -59,6 +60,36 @@ test("public navigation does not overflow narrow or wide viewports", async ({ br
expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth); expect(dimensions.scrollWidth).toBeLessThanOrEqual(dimensions.clientWidth);
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible(); await expect(page.getByRole("navigation", { name: "Primary navigation" })).toBeVisible();
await expect(page.locator("body > header")).toHaveCount(1);
await expect(page.locator("body > nav[aria-label='Account and language']")).toHaveCount(0);
if (viewport.width < 1280) {
const menuButton = page.locator("summary[aria-label='Primary navigation']");
await expect(menuButton).toBeVisible();
await menuButton.click();
await expect(page.getByRole("link", { name: "Requests", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Activities", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Categories", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Helpers", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "English", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Log in", exact: true })).toBeVisible();
const openDimensions = await page.evaluate(() => ({
clientWidth: document.documentElement.clientWidth,
scrollWidth: document.documentElement.scrollWidth,
}));
expect(openDimensions.scrollWidth).toBeLessThanOrEqual(openDimensions.clientWidth);
} else {
await expect(page.locator("summary[aria-label='Primary navigation']")).toBeHidden();
await expect(page.getByRole("link", { name: "Requests", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "English", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Register", exact: true })).toBeVisible();
await expect(page.getByRole("link", { name: "Log in", exact: true })).toBeVisible();
}
await context.close(); await context.close();
} }
}); });

View File

@ -35,63 +35,100 @@ defmodule WhoNeedHelpWeb.Layouts do
def app(assigns) do def app(assigns) do
~H""" ~H"""
<header class="sticky top-0 z-30 flex flex-col items-stretch gap-2 border-b border-base-300 bg-base-100/90 px-4 py-2 backdrop-blur sm:flex-row sm:items-center sm:px-6 lg:px-8"> <header class="sticky top-0 z-30 border-b border-base-300 bg-base-100/90 px-4 py-2 backdrop-blur sm:px-6 lg:px-8">
<div class="flex-1"> <div class="mx-auto flex min-h-12 w-full max-w-7xl items-center justify-between gap-3">
<a href="/" class="flex w-fit items-center gap-3" aria-label={gettext("Who Need Help home")}> <a
<span class="grid size-9 place-items-center rounded-xl bg-success text-xl text-success-content">+</span> href="/"
<span class="font-black tracking-tight">Who Need Help</span> class="flex min-w-0 items-center gap-3"
aria-label={gettext("Who Need Help home")}
>
<span class="grid size-9 shrink-0 place-items-center rounded-xl bg-success text-xl text-success-content">
+
</span>
<span class="truncate font-black tracking-tight">Who Need Help</span>
</a> </a>
<nav class="min-w-0 flex-none" aria-label={gettext("Primary navigation")}>
<div class="hidden items-center gap-1 xl:flex">
<ul class="flex items-center gap-0.5">
<.primary_navigation_links current_scope={@current_scope} />
</ul>
<div
class="ml-1 flex items-center gap-1 border-l border-base-300 pl-2"
aria-label={gettext("Account and language")}
>
<.locale_switcher />
<.theme_toggle />
<%= if @current_scope do %>
<.link navigate={~p"/requests/new"} class="btn btn-primary btn-sm whitespace-nowrap">
{gettext("Ask for help")}
</.link>
<.account_menu current_scope={@current_scope} />
<% else %>
<.link navigate={~p"/users/register"} class="btn btn-ghost btn-sm">
{gettext("Register")}
</.link>
<.link navigate={~p"/users/log-in"} class="btn btn-primary btn-sm">
{gettext("Log in")}
</.link>
<% end %>
</div>
</div>
<details class="dropdown dropdown-end xl:hidden">
<summary
class="btn btn-ghost btn-square"
aria-label={gettext("Primary navigation")}
>
<.icon name="hero-bars-3" class="size-6" />
</summary>
<div class="dropdown-content mt-3 w-[min(20rem,calc(100vw-2rem))] overflow-hidden rounded-box border border-base-300 bg-base-100 shadow-xl">
<ul class="menu w-full p-2">
<.primary_navigation_links
current_scope={@current_scope}
link_class="justify-start"
/>
</ul>
<div class="flex flex-wrap items-center justify-between gap-3 border-t border-base-300 px-4 py-3">
<.locale_switcher />
<.theme_toggle />
</div>
<%= if @current_scope do %>
<ul class="menu w-full border-t border-base-300 p-2">
<li>
<.link navigate={~p"/profile"}>{gettext("Profile")}</.link>
</li>
<li>
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>
</li>
</ul>
<div class="border-t border-base-300 p-3">
<.link navigate={~p"/requests/new"} class="btn btn-primary btn-block">
{gettext("Ask for help")}
</.link>
</div>
<% else %>
<div class="grid grid-cols-2 gap-2 border-t border-base-300 p-3">
<.link navigate={~p"/users/register"} class="btn btn-outline">
{gettext("Register")}
</.link>
<.link navigate={~p"/users/log-in"} class="btn btn-primary">
{gettext("Log in")}
</.link>
</div>
<% end %>
</div>
</details>
</nav>
</div> </div>
<nav class="min-w-0 flex-none" aria-label={gettext("Primary navigation")}>
<ul class="flex max-w-full items-center gap-1 overflow-x-auto px-1 pb-1 sm:pb-0">
<li>
<.link navigate={~p"/requests"} class="btn btn-ghost btn-sm">
{gettext("Requests")}
</.link>
</li>
<li>
<.link navigate={~p"/activities"} class="btn btn-ghost btn-sm">
{gettext("Activities")}
</.link>
</li>
<li>
<.link navigate={~p"/categories/proposals"} class="btn btn-ghost btn-sm">
{gettext("Categories")}
</.link>
</li>
<li>
<.link navigate={~p"/leaderboard"} class="btn btn-ghost btn-sm">
{gettext("Helpers")}
</.link>
</li>
<li>
<.theme_toggle />
</li>
<%= if @current_scope do %>
<li :if={@current_scope.user.role in [:moderator, :admin]}>
<.link navigate={~p"/moderation"} class="btn btn-warning btn-sm">
{gettext("Moderation")}
</.link>
</li>
<li>
<.link navigate={~p"/profile"} class="btn btn-ghost btn-sm">
{gettext("Profile")}
</.link>
</li>
<li>
<.link navigate={~p"/requests/new"} class="btn btn-primary btn-sm">
{gettext("Ask for help")}
</.link>
</li>
<% else %>
<li>
<.link navigate={~p"/users/log-in"} class="btn btn-primary btn-sm">
{gettext("Log in")}
</.link>
</li>
<% end %>
</ul>
</nav>
</header> </header>
<main id="main-content" tabindex="-1" class="px-4 py-8 outline-none sm:px-6 lg:px-8"> <main id="main-content" tabindex="-1" class="px-4 py-8 outline-none sm:px-6 lg:px-8">
@ -110,6 +147,120 @@ defmodule WhoNeedHelpWeb.Layouts do
""" """
end end
attr :current_scope, :map, default: nil
attr :link_class, :string, default: "btn btn-ghost btn-sm"
defp primary_navigation_links(assigns) do
~H"""
<li>
<.link navigate={~p"/requests"} class={@link_class}>
{gettext("Requests")}
</.link>
</li>
<li>
<.link navigate={~p"/activities"} class={@link_class}>
{gettext("Activities")}
</.link>
</li>
<li>
<.link navigate={~p"/categories/proposals"} class={@link_class}>
{gettext("Categories")}
</.link>
</li>
<li>
<.link navigate={~p"/leaderboard"} class={@link_class}>
{gettext("Helpers")}
</.link>
</li>
<li :if={@current_scope && @current_scope.user.role in [:moderator, :admin]}>
<.link navigate={~p"/moderation"} class={["btn-warning", @link_class]}>
{gettext("Moderation")}
</.link>
</li>
"""
end
defp locale_switcher(assigns) do
~H"""
<div
class="join"
role="group"
aria-label={gettext("Account and language")}
>
<a
href="?locale=en"
class={[
"btn btn-ghost btn-xs join-item",
Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "en" && "btn-active"
]}
aria-label="English"
aria-current={Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "en" && "page"}
>
EN
</a>
<a
href="?locale=uk"
class={[
"btn btn-ghost btn-xs join-item",
Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "uk" && "btn-active"
]}
aria-label="Українська"
aria-current={Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "uk" && "page"}
>
UK
</a>
<a
href="?locale=ru"
class={[
"btn btn-ghost btn-xs join-item",
Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "ru" && "btn-active"
]}
aria-label="Русский"
aria-current={Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "ru" && "page"}
>
RU
</a>
</div>
"""
end
attr :current_scope, :map, required: true
defp account_menu(assigns) do
~H"""
<details class="dropdown dropdown-end">
<summary class="btn btn-ghost btn-sm gap-1">
<.icon name="hero-user-circle" class="size-5" />
<span class="flex max-w-36 flex-col items-start overflow-hidden leading-tight">
<span class="max-w-full truncate">
{@current_scope.user.display_name || @current_scope.user.email}
</span>
<span
:if={@current_scope.user.display_name}
class="max-w-full truncate text-[0.65rem] font-normal opacity-60"
>
{@current_scope.user.email}
</span>
</span>
<.icon name="hero-chevron-down-micro" class="size-4 opacity-60" />
</summary>
<div class="dropdown-content mt-3 w-64 overflow-hidden rounded-box border border-base-300 bg-base-100 shadow-xl">
<ul class="menu w-full p-2">
<li>
<.link navigate={~p"/profile"}>{gettext("Profile")}</.link>
</li>
<li>
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>
</li>
</ul>
</div>
</details>
"""
end
@doc """ @doc """
Shows the flash group with standard titles and content. Shows the flash group with standard titles and content.

View File

@ -51,57 +51,6 @@
> >
{gettext("Skip to main content")} {gettext("Skip to main content")}
</a> </a>
<nav aria-label={gettext("Account and language")}>
<ul class="menu menu-horizontal relative z-40 flex w-full flex-wrap items-center justify-end gap-2 bg-base-100 px-4 text-xs sm:px-6 lg:px-8">
<%= if @current_scope do %>
<li>
<span class="flex flex-col items-end leading-tight">
<span>{@current_scope.user.display_name || @current_scope.user.email}</span>
<span class="text-[0.65rem] text-base-content/50">{@current_scope.user.email}</span>
</span>
</li>
<li>
<.link href={~p"/profile"}>{gettext("Profile")}</.link>
</li>
<li>
<.link href={~p"/users/settings"}>{gettext("Account settings")}</.link>
</li>
<li>
<.link href={~p"/users/log-out"} method="delete">{gettext("Log out")}</.link>
</li>
<% else %>
<li>
<.link href={~p"/users/register"}>{gettext("Register")}</.link>
</li>
<li>
<.link href={~p"/users/log-in"}>{gettext("Log in")}</.link>
</li>
<% end %>
<li class="flex flex-row gap-0">
<a
href="?locale=en"
aria-label="English"
aria-current={Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "en" && "page"}
>
EN
</a>
<a
href="?locale=uk"
aria-label="Українська"
aria-current={Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "uk" && "page"}
>
UK
</a>
<a
href="?locale=ru"
aria-label="Русский"
aria-current={Gettext.get_locale(WhoNeedHelpWeb.Gettext) == "ru" && "page"}
>
RU
</a>
</li>
</ul>
</nav>
{@inner_content} {@inner_content}
</body> </body>
</html> </html>

View File

@ -3,10 +3,20 @@ defmodule WhoNeedHelpWeb.PageControllerTest do
test "GET /", %{conn: conn} do test "GET /", %{conn: conn} do
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Help can be closer than you think." html = html_response(conn, 200)
document = LazyHTML.from_document(html)
assert html_response(conn, 200) =~ assert html =~ "Help can be closer than you think."
assert html =~
~s(data-map-tile-url="https://tile.openstreetmap.org/{z}/{x}/{y}.png") ~s(data-map-tile-url="https://tile.openstreetmap.org/{z}/{x}/{y}.png")
assert Enum.count(LazyHTML.query(document, "body > header")) == 1
assert Enum.count(LazyHTML.query(document, "header nav[aria-label='Primary navigation']")) ==
1
assert Enum.empty?(LazyHTML.query(document, "body > nav[aria-label='Account and language']"))
end end
test "GET / selects Russian locale", %{conn: conn} do test "GET / selects Russian locale", %{conn: conn} do