diff --git a/templates/base/head_navbar.tmpl b/templates/base/head_navbar.tmpl
index 93ea907dfa..85eee5b1df 100644
--- a/templates/base/head_navbar.tmpl
+++ b/templates/base/head_navbar.tmpl
@@ -55,24 +55,27 @@
-
-
+
+
{{svg "octicon-plus"}}
{{svg "octicon-triangle-down"}}
{{ctx.Locale.Tr "create_new"}}
-
-
-
+
+
+
-
-
- {{ctx.AvatarUtils.Avatar .SignedUser 24 "tw-mr-1"}}
+
+
+ {{ctx.AvatarUtils.Avatar .SignedUser 24}}
{{.SignedUser.Name}}
{{svg "octicon-triangle-down"}}
-
-
-
+
+
+
{{else}}
{{if .ShowRegistrationButton}}
diff --git a/tests/e2e/dropdown.test.e2e.ts b/tests/e2e/dropdown.test.e2e.ts
index 1cca75e75d..c4098e102a 100644
--- a/tests/e2e/dropdown.test.e2e.ts
+++ b/tests/e2e/dropdown.test.e2e.ts
@@ -19,9 +19,10 @@ test('JS enhanced interaction', async ({page}) => {
await expect(nojsNotice).toBeHidden();
// Open and close by clicking summary
- const dropdown = page.locator('details.dropdown');
- const dropdownSummary = page.locator('details.dropdown > summary');
- const dropdownContent = page.locator('details.dropdown > .content');
+ const selectorPrefix = '#profile-avatar-card details.dropdown';
+ const dropdown = page.locator(selectorPrefix);
+ const dropdownSummary = page.locator(`${selectorPrefix} > summary`);
+ const dropdownContent = page.locator(`${selectorPrefix} > .content`);
await expect(dropdownContent).toBeHidden();
await dropdownSummary.click();
await expect(dropdownContent).toBeVisible();
@@ -116,8 +117,9 @@ test('No JS interaction', async ({browser}) => {
await expect(nojsPage.locator('body')).toContainClass('no-js');
// Open and close by clicking summary
- const dropdownSummary = nojsPage.locator('details.dropdown > summary');
- const dropdownContent = nojsPage.locator('details.dropdown > .content');
+ const selectorPrefix = '#profile-avatar-card details.dropdown';
+ const dropdownSummary = nojsPage.locator(`${selectorPrefix} > summary`);
+ const dropdownContent = nojsPage.locator(`${selectorPrefix} > .content`);
await expect(dropdownContent).toBeHidden();
await dropdownSummary.click();
await expect(dropdownContent).toBeVisible();
@@ -151,23 +153,7 @@ test('No JS interaction', async ({browser}) => {
await expect(dropdownContent).toBeVisible();
});
-test('Visual properties', async ({browser, isMobile}) => {
- const context = await browser.newContext({javaScriptEnabled: false});
- const page = await context.newPage();
-
- // User profile has dropdown used as an ellipsis menu
- await page.goto('/user1');
-
- // Has `.border` and pretty small default `inline-padding:`
- const summary = page.locator('details.dropdown > summary');
- expect(await summary.evaluate((el) => getComputedStyle(el).border)).toBe('1px solid rgba(0, 0, 0, 0.114)');
- expect(await summary.evaluate((el) => getComputedStyle(el).paddingInline)).toBe('7px');
-
- // Background
- expect(await summary.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
- await summary.click();
- expect(await summary.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(226, 226, 229)');
-
+test.describe(`Visual properties`, () => {
async function evaluateDropdownItems(page, selector, direction, height) {
const computedStyles = await page.locator(selector).evaluateAll((items) =>
items.map((item) => {
@@ -184,36 +170,60 @@ test('Visual properties', async ({browser, isMobile}) => {
}
}
- // Direction and item height
- const content = page.locator('details.dropdown > .content');
- const itemsSel = 'details.dropdown > .content > ul > li';
- if (isMobile) {
+ test('User profile', async ({browser, isMobile}) => {
+ const context = await browser.newContext({javaScriptEnabled: false});
+ const page = await context.newPage();
+
+ // User profile has dropdown used as an ellipsis menu
+ await page.goto('/user1');
+ const selectorPrefix = '#profile-avatar-card details.dropdown';
+ const summary = page.locator(`${selectorPrefix} > summary`);
+
+ // Has `.border` and pretty small default `inline-padding:`
+ expect(await summary.evaluate((el) => getComputedStyle(el).border)).toBe('1px solid rgba(0, 0, 0, 0.114)');
+ expect(await summary.evaluate((el) => getComputedStyle(el).paddingInline)).toBe('7px');
+
+ // Background
+ expect(await summary.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
+ await summary.click();
+ expect(await summary.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(226, 226, 229)');
+
+ // Direction and item height
+ if (isMobile) {
+ // ``'s direction is reversed
+ expect(await page.locator(`${selectorPrefix} > .content`).evaluate((el) => getComputedStyle(el).direction)).toBe('rtl');
+ // `@media (pointer: coarse)` makes items taller
+ await evaluateDropdownItems(page, `${selectorPrefix} > .content > ul > li`, 'ltr', '40px');
+ } else {
+ // Both use default direction
+ expect(await page.locator(`${selectorPrefix} > .content`).evaluate((el) => getComputedStyle(el).direction)).toBe('ltr');
+ // Regular item height
+ await evaluateDropdownItems(page, `${selectorPrefix} > .content > ul > li`, 'ltr', '34px');
+ }
+ });
+
+ test('Explore sort', async ({browser, isMobile}) => {
+ const context = await browser.newContext({javaScriptEnabled: false});
+ const page = await context.newPage();
+
+ // `/explore/users` has dropdown used as a sort options menu with text in the opener
+ await page.goto('/explore/users');
+ const selectorPrefix = '.list-header details.dropdown';
+ const summary = page.locator(`${selectorPrefix} > summary`);
+ await summary.click();
+
+ // No `.border` and increased `inline-padding:` from `.options`
+ expect(await summary.evaluate((el) => getComputedStyle(el).borderWidth)).toBe('0px');
+ expect(await summary.evaluate((el) => getComputedStyle(el).paddingInline)).toBe('10.5px');
+
// ``'s direction is reversed
- expect(await content.evaluate((el) => getComputedStyle(el).direction)).toBe('rtl');
- // `@media (pointer: coarse)` makes items taller
- await evaluateDropdownItems(page, itemsSel, 'ltr', '40px');
- } else {
- // Both use default direction
- expect(await content.evaluate((el) => getComputedStyle(el).direction)).toBe('ltr');
- // Regular item height
- await evaluateDropdownItems(page, itemsSel, 'ltr', '34px');
- }
+ expect(await page.locator(`${selectorPrefix} > .content`).evaluate((el) => getComputedStyle(el).direction)).toBe('rtl');
+ await evaluateDropdownItems(page, `${selectorPrefix} > .content > ul > li`, 'ltr', isMobile ? '40px' : '34px');
- // `/explore/users` has dropdown used as a sort options menu with text in the opener
- await page.goto('/explore/users');
- await summary.click();
-
- // No `.border` and increased `inline-padding:` from `.options`
- expect(await summary.evaluate((el) => getComputedStyle(el).borderWidth)).toBe('0px');
- expect(await summary.evaluate((el) => getComputedStyle(el).paddingInline)).toBe('10.5px');
-
- // ``'s direction is reversed
- expect(await content.evaluate((el) => getComputedStyle(el).direction)).toBe('rtl');
- await evaluateDropdownItems(page, itemsSel, 'ltr', isMobile ? '40px' : '34px');
-
- // Background of inactive and `.active` items
- const activeItem = page.locator('details.dropdown > .content > ul > li:first-child > a');
- const inactiveItem = page.locator('details.dropdown > .content > ul > li:last-child > a');
- expect(await activeItem.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(226, 226, 229)');
- expect(await inactiveItem.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
+ // Background of inactive and `.active` items
+ const activeItem = page.locator(`${selectorPrefix}> .content > ul > li:first-child > a`);
+ const inactiveItem = page.locator(`${selectorPrefix}> .content > ul > li:last-child > a`);
+ expect(await activeItem.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(226, 226, 229)');
+ expect(await inactiveItem.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
+ });
});
diff --git a/tests/e2e/webauthn.test.e2e.ts b/tests/e2e/webauthn.test.e2e.ts
index 85337384f4..99508e6141 100644
--- a/tests/e2e/webauthn.test.e2e.ts
+++ b/tests/e2e/webauthn.test.e2e.ts
@@ -40,7 +40,7 @@ test('WebAuthn register & login flow', async ({browser, request}, workerInfo) =>
await expect(page.getByRole('button', {name: 'Remove'})).toBeVisible(); // "Remove" button is visible, indicating that the security key was added
// Logout.
- await page.locator('div[aria-label="Profile and settings…"]').click();
+ await page.locator('summary[aria-label="Profile and settings…"]').click();
await page.getByText('Sign out').click();
await expect(async () => {
await page.waitForURL(`${workerInfo.project.use.baseURL}/`);
diff --git a/tests/integration/common_navigation_test.go b/tests/integration/common_navigation_test.go
index 93a2c15ece..0a1684394f 100644
--- a/tests/integration/common_navigation_test.go
+++ b/tests/integration/common_navigation_test.go
@@ -6,7 +6,6 @@ package integration
import (
"fmt"
"net/http"
- "strings"
"testing"
"forgejo.org/models/unittest"
@@ -27,12 +26,6 @@ func TestCommonNavigationElements(t *testing.T) {
response := session.MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
page := NewHTMLParser(t, response.Body)
- // Navbar
- links := page.Find("#navbar .dropdown[data-tooltip-content='Create…'] .menu")
- assert.Equal(t, locale.TrString("new_repo.link"), strings.TrimSpace(links.Find("a[href='/repo/create']").Text()))
- assert.Equal(t, locale.TrString("new_migrate.link"), strings.TrimSpace(links.Find("a[href='/repo/migrate']").Text()))
- assert.Equal(t, locale.TrString("new_org.link"), strings.TrimSpace(links.Find("a[href='/org/create']").Text()))
-
// After footer: index.js
page.AssertElement(t, "script[src^='/assets/js/index.js']", true)
onerror, _ := page.Find("script[src^='/assets/js/index.js']").Attr("onerror")
diff --git a/tests/integration/navbar_test.go b/tests/integration/navbar_test.go
new file mode 100644
index 0000000000..b260b93014
--- /dev/null
+++ b/tests/integration/navbar_test.go
@@ -0,0 +1,111 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package integration
+
+import (
+ "net/http"
+ "strings"
+ "testing"
+
+ "forgejo.org/modules/setting"
+ "forgejo.org/modules/test"
+ "forgejo.org/modules/translation"
+ "forgejo.org/tests"
+
+ "github.com/stretchr/testify/assert"
+)
+
+/* TestNavbarItems asserts go tmpl logic of navbar */
+func TestNavbarItems(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ // The navbar can be tested on any page, but preferably a lightweight one
+ testPage := "/explore/organizations"
+ locale := translation.NewLocale("en-US")
+
+ adminUser := loginUser(t, "user1")
+ regularUser := loginUser(t, "user2")
+
+ t.Run(`"Create..." dropdown - migrations disallowed`, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Repository.DisableMigrations, true)()
+
+ page := NewHTMLParser(t, regularUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
+ page.AssertElement(t, `details.dropdown a[href="/repo/migrate"]`, false)
+ })
+
+ t.Run(`"Create..." dropdown - creating orgs disallowed`, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Admin.DisableRegularOrgCreation, true)()
+
+ // The restriction applies to a regular user
+ page := NewHTMLParser(t, regularUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
+ page.AssertElement(t, `details.dropdown a[href="/org/create"]`, false)
+
+ // The restriction does not apply to an admin
+ page = NewHTMLParser(t, adminUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
+ page.AssertElement(t, `details.dropdown a[href="/org/create"]`, true)
+ })
+
+ t.Run(`"Create..." dropdown - default conditions`, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ // Assert that items are present and their contents
+ assertItems := func(t *testing.T, session *TestSession) {
+ page := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
+ links := page.Find(`#navbar .dropdown:has(summary[data-tooltip-content="Create…"]) .content`)
+ assert.Equal(t, locale.TrString("new_repo.link"), strings.TrimSpace(links.Find(`a[href="/repo/create"]`).Text()))
+ assert.Equal(t, locale.TrString("new_migrate.link"), strings.TrimSpace(links.Find(`a[href="/repo/migrate"]`).Text()))
+ assert.Equal(t, locale.TrString("new_org.link"), strings.TrimSpace(links.Find(`a[href="/org/create"]`).Text()))
+ }
+ assertItems(t, regularUser)
+ assertItems(t, adminUser)
+ })
+
+ t.Run(`User dropdown - stars are disabled`, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Repository.DisableStars, true)()
+
+ page := NewHTMLParser(t, regularUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
+ page.AssertElement(t, `details.dropdown a[href$="?tab=stars"]`, false)
+ })
+
+ t.Run(`User dropdown - default conditions`, func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+
+ assertions := []struct {
+ selector string
+ exists bool
+ }{
+ {`details.dropdown a[href="/user2"]`, true},
+ {`details.dropdown a[href="/user2?tab=stars"]`, true},
+ {`details.dropdown a[href="/notifications/subscriptions"]`, true},
+ {`details.dropdown a[href="/user/settings"]`, true},
+ {`details.dropdown a[href="/admin"]`, false},
+ {`details.dropdown a[href="https://forgejo.org/docs/latest/"]`, true},
+ {`details.dropdown a[data-url="/user/logout"]`, true},
+ }
+ page := NewHTMLParser(t, regularUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
+ for _, assertion := range assertions {
+ page.AssertElement(t, assertion.selector, assertion.exists)
+ }
+
+ assertions = []struct {
+ selector string
+ exists bool
+ }{
+ {`details.dropdown a[href="/user1"]`, true},
+ {`details.dropdown a[href="/user1?tab=stars"]`, true},
+ {`details.dropdown a[href="/notifications/subscriptions"]`, true},
+ {`details.dropdown a[href="/user/settings"]`, true},
+ {`details.dropdown a[href="/admin"]`, true},
+ {`details.dropdown a[href="https://forgejo.org/docs/latest/"]`, true},
+ {`details.dropdown a[data-url="/user/logout"]`, true},
+ }
+ page = NewHTMLParser(t, adminUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
+ for _, assertion := range assertions {
+ page.AssertElement(t, assertion.selector, assertion.exists)
+ }
+ })
+}
diff --git a/tests/integration/org_profile_test.go b/tests/integration/org_profile_test.go
index d806f6103a..fdc9ddbf50 100644
--- a/tests/integration/org_profile_test.go
+++ b/tests/integration/org_profile_test.go
@@ -120,12 +120,12 @@ quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequa
// Both guests and logged in users should see the feed option
doc := NewHTMLParser(t, MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
- doc.AssertElement(t, "details.dropdown a[href='/org3.rss']", true)
- doc.AssertElement(t, "details.dropdown a[href^='/report_abuse']", false)
+ doc.AssertElement(t, ".org-header details.dropdown a[href='/org3.rss']", true)
+ doc.AssertElement(t, ".org-header details.dropdown a[href^='/report_abuse']", false)
doc = NewHTMLParser(t, loginUser(t, "user10").MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
- doc.AssertElement(t, "details.dropdown a[href='/org3.rss']", true)
- doc.AssertElement(t, "details.dropdown a[href^='/report_abuse']", false)
+ doc.AssertElement(t, ".org-header details.dropdown a[href='/org3.rss']", true)
+ doc.AssertElement(t, ".org-header details.dropdown a[href^='/report_abuse']", false)
})
t.Run("More actions - none", func(t *testing.T) {
@@ -135,10 +135,10 @@ quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequa
// The dropdown won't appear if no entries are available, for both guests and logged in users
doc := NewHTMLParser(t, MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
- doc.AssertElement(t, "details.dropdown", false)
+ doc.AssertElement(t, ".org-header details.dropdown", false)
doc = NewHTMLParser(t, loginUser(t, "user10").MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
- doc.AssertElement(t, "details.dropdown", false)
+ doc.AssertElement(t, ".org-header details.dropdown", false)
})
t.Run("More actions - moderation", func(t *testing.T) {
@@ -148,15 +148,15 @@ quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequa
// The report option shouldn't be available to a guest
doc := NewHTMLParser(t, MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
- doc.AssertElement(t, "details.dropdown", false)
+ doc.AssertElement(t, ".org-header details.dropdown", false)
// But should be available to a logged in user
doc = NewHTMLParser(t, loginUser(t, "user10").MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
- doc.AssertElement(t, "details.dropdown a[href^='/report_abuse']", true)
+ doc.AssertElement(t, ".org-header details.dropdown a[href^='/report_abuse']", true)
// But the org owner shouldn't see the report option
doc = NewHTMLParser(t, loginUser(t, "user1").MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
- doc.AssertElement(t, "details.dropdown", false)
+ doc.AssertElement(t, ".org-header details.dropdown", false)
})
})
}
diff --git a/tests/integration/signin_test.go b/tests/integration/signin_test.go
index 660e24a825..6db2b51aea 100644
--- a/tests/integration/signin_test.go
+++ b/tests/integration/signin_test.go
@@ -169,7 +169,7 @@ func TestGlobalTwoFactorRequirement(t *testing.T) {
resp := session.MakeRequest(t, req, http.StatusNotFound)
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Greater(t, htmlDoc.Find(".navbar-left > a.item").Length(), 1) // show the Logo, and other links
- assert.Greater(t, htmlDoc.Find(".navbar-right .user-menu a.item").Length(), 1)
+ assert.Greater(t, htmlDoc.Find(".navbar-right details.dropdown a").Length(), 1)
// 500 page
reset := enableDevtest()
@@ -191,7 +191,7 @@ func TestGlobalTwoFactorRequirement(t *testing.T) {
htmlDoc := NewHTMLParser(t, resp.Body)
assert.Equal(t, 1, htmlDoc.Find(".navbar-left > a.item").Length()) // only show the Logo, no other links
- userLinks := htmlDoc.Find(".navbar-right .user-menu a.item")
+ userLinks := htmlDoc.Find(".navbar-right details.dropdown a")
assert.Equal(t, 1, userLinks.Length()) // only logout link
assert.Equal(t, "Sign out", strings.TrimSpace(userLinks.Text()))
@@ -212,7 +212,7 @@ func TestGlobalTwoFactorRequirement(t *testing.T) {
assert.Equal(t, locale.TrString("settings.must_enable_2fa"), htmlDoc.Find(".ui.red.message").Text())
assert.Equal(t, 1, htmlDoc.Find(".navbar-left > a.item").Length()) // only show the Logo, no other links
- userLinks = htmlDoc.Find(".navbar-right .user-menu a.item")
+ userLinks = htmlDoc.Find(".navbar-right details.dropdown a")
assert.Equal(t, 1, userLinks.Length()) // only logout link
assert.Equal(t, "Sign out", strings.TrimSpace(userLinks.Text()))
diff --git a/web_src/css/modules/dropdown.css b/web_src/css/modules/dropdown.css
index 80625461f8..dc7e81ed7b 100644
--- a/web_src/css/modules/dropdown.css
+++ b/web_src/css/modules/dropdown.css
@@ -15,13 +15,13 @@
:root details.dropdown {
--dropdown-box-shadow: 0 6px 18px var(--color-shadow);
--dropdown-item-min-height: 34px;
- --switch-padding-inline: 0.75rem;
+ --dropdown-padding-inline: 0.75rem;
}
@media (pointer: coarse) {
:root details.dropdown {
--dropdown-item-min-height: 40px;
- --switch-padding-inline: 1rem;
+ --dropdown-padding-inline: 1rem;
}
}
@@ -66,12 +66,12 @@ details.dropdown > summary {
}
details.dropdown > summary:hover,
-details.dropdown > .content > ul > li:hover {
+details.dropdown > .content > ul > li > :is(a, button):hover {
background: var(--color-hover);
}
details.dropdown[open] > summary,
-details.dropdown > .content > ul > li:focus-within {
+details.dropdown > .content > ul > li:focus-within > :is(a, button) {
background: var(--color-active);
}
@@ -86,7 +86,7 @@ details.dropdown > .content {
margin-top: 0.5rem;
/* ToDo: upstream to base.css, remove from normalize.css */
- > hr {
+ hr {
height: 1px;
margin-block: 0.25rem;
background-color: var(--color-secondary);
@@ -112,17 +112,24 @@ details.dropdown > .content > ul {
/* General styling of list items */
details.dropdown > .content > ul > li {
width: 100%;
- background: none;
> :is(a, button) {
- padding-block: 0;
- padding-inline: var(--switch-padding-inline);
min-height: var(--dropdown-item-min-height);
+ padding-block: 0;
+
width: 100%;
+ padding-inline: var(--dropdown-padding-inline);
display: flex;
gap: 0.75rem;
align-items: center;
color: var(--color-text);
+
+ /* Interactable items should be transparent by default.