diff --git a/tests/e2e/dropdown.test.e2e.ts b/tests/e2e/dropdown.test.e2e.ts
index cc73a20f4d..75e44ae1d8 100644
--- a/tests/e2e/dropdown.test.e2e.ts
+++ b/tests/e2e/dropdown.test.e2e.ts
@@ -2,7 +2,9 @@
// SPDX-License-Identifier: GPL-3.0-or-later
// @watch start
-// templates/shared/user/**
+// templates/shared/user/actions_menu.tmpl
+// templates/org/header.tmpl
+// templates/explore/search.tmpl
// web_src/js/modules/dropdown.ts
// @watch end
@@ -17,8 +19,8 @@ test('JS enhanced interaction', async ({page}) => {
await expect(nojsNotice).toBeHidden();
// Open and close by clicking summary
- const dropdownSummary = page.locator('details.dropdown summary');
- const dropdownContent = page.locator('details.dropdown ul');
+ const dropdownSummary = page.locator('details.dropdown > summary');
+ const dropdownContent = page.locator('details.dropdown > .content');
await expect(dropdownContent).toBeHidden();
await dropdownSummary.click();
await expect(dropdownContent).toBeVisible();
@@ -50,7 +52,7 @@ test('JS enhanced interaction', async ({page}) => {
await dropdownSummary.press(`Escape`);
await expect(dropdownContent).toBeHidden();
- // Open and close by opening a different dropdown
+ // Open and then close by opening a different dropdown
const languageMenu = page.locator('.language-menu');
await dropdownSummary.click();
await expect(dropdownContent).toBeVisible();
@@ -70,8 +72,8 @@ 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 ul');
+ const dropdownSummary = nojsPage.locator('details.dropdown > summary');
+ const dropdownContent = nojsPage.locator('details.dropdown > .content');
await expect(dropdownContent).toBeHidden();
await dropdownSummary.click();
await expect(dropdownContent).toBeVisible();
@@ -113,7 +115,7 @@ test('Visual properties', async ({browser, isMobile}) => {
await page.goto('/user1');
// Has `.border` and pretty small default `inline-padding:`
- const summary = page.locator('details.dropdown summary');
+ 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');
@@ -139,8 +141,8 @@ test('Visual properties', async ({browser, isMobile}) => {
}
// Direction and item height
- const content = page.locator('details.dropdown > ul');
- const itemsSel = 'details.dropdown > ul > li';
+ const content = page.locator('details.dropdown > .content');
+ const itemsSel = 'details.dropdown > .content > ul > li';
if (isMobile) {
// `
`'s direction is reversed
expect(await content.evaluate((el) => getComputedStyle(el).direction)).toBe('rtl');
@@ -166,8 +168,8 @@ test('Visual properties', async ({browser, isMobile}) => {
await evaluateDropdownItems(page, itemsSel, 'ltr', isMobile ? '40px' : '34px');
// Background of inactive and `.active` items
- const activeItem = page.locator('details.dropdown > ul > li:first-child > a');
- const inactiveItem = page.locator('details.dropdown > ul > li:last-child > a');
+ 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)');
});
diff --git a/tests/integration/explore_org_test.go b/tests/integration/explore_org_test.go
index aeefefa07f..a4f6fc79b9 100644
--- a/tests/integration/explore_org_test.go
+++ b/tests/integration/explore_org_test.go
@@ -32,7 +32,7 @@ func TestExploreOrg(t *testing.T) {
req := NewRequest(t, "GET", "/explore/organizations?sort="+c.sortOrder)
resp := MakeRequest(t, req, http.StatusOK)
h := NewHTMLParser(t, resp.Body)
- href, _ := h.Find(`.list-header details.dropdown > ul > li > a.active[href^="?sort="]`).Attr("href")
+ href, _ := h.Find(`.list-header details.dropdown > .content > ul > li > a.active[href^="?sort="]`).Attr("href")
assert.Equal(t, c.expected, href)
}
diff --git a/tests/integration/explore_user_test.go b/tests/integration/explore_user_test.go
index 2d46bf2d6e..24c2eb63b1 100644
--- a/tests/integration/explore_user_test.go
+++ b/tests/integration/explore_user_test.go
@@ -32,7 +32,7 @@ func TestExploreUser(t *testing.T) {
req := NewRequest(t, "GET", "/explore/users?sort="+c.sortOrder)
resp := MakeRequest(t, req, http.StatusOK)
h := NewHTMLParser(t, resp.Body)
- href, _ := h.Find(`.list-header details.dropdown > ul > li > a.active[href^="?sort="]`).Attr("href")
+ href, _ := h.Find(`.list-header details.dropdown > .content > ul > li > a.active[href^="?sort="]`).Attr("href")
assert.Equal(t, c.expected, href)
}
diff --git a/tests/integration/user_profile_actions_test.go b/tests/integration/user_profile_actions_test.go
new file mode 100644
index 0000000000..905f0c079d
--- /dev/null
+++ b/tests/integration/user_profile_actions_test.go
@@ -0,0 +1,84 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+package integration
+
+import (
+ "net/http"
+ "testing"
+
+ "forgejo.org/modules/setting"
+ "forgejo.org/modules/test"
+ "forgejo.org/tests"
+)
+
+func TestUserProfileActions(t *testing.T) {
+ defer tests.PrepareTestEnv(t)()
+
+ admSel := `details.dropdown a[href^="/admin/users/"]`
+ blockSel := `details.dropdown button[hx-post$="?action=block"]`
+ reportSel := `details.dropdown a[href^="/report_abuse?type=user"]`
+
+ t.Run("Guest user", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Moderation.Enabled, true)()
+
+ // Can't do much
+ page := NewHTMLParser(t, MakeRequest(t, NewRequest(t, "GET", "/user1"), http.StatusOK).Body)
+ page.AssertElement(t, admSel, false)
+ page.AssertElement(t, blockSel, false)
+ page.AssertElement(t, reportSel, false)
+ })
+
+ t.Run("User blocking", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Moderation.Enabled, true)()
+
+ session := loginUser(t, "user2")
+
+ // Can block others
+ page := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "/user1"), http.StatusOK).Body)
+ page.AssertElement(t, blockSel, true)
+
+ // Can't block self
+ page = NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "/user2"), http.StatusOK).Body)
+ page.AssertElement(t, blockSel, false)
+ })
+
+ // To decrease the amount of requests, admin and moderation assertions are squashed together
+
+ t.Run("Moderation enabled, user is admin", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Moderation.Enabled, true)()
+
+ session := loginUser(t, "user1")
+ // The /admin/... link advertized to admins on all profiles
+
+ // Can report others
+ page := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "/user2"), http.StatusOK).Body)
+ page.AssertElement(t, reportSel, true)
+ page.AssertElement(t, admSel, true)
+
+ // Can't report self
+ page = NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "/user1"), http.StatusOK).Body)
+ page.AssertElement(t, reportSel, false)
+ page.AssertElement(t, admSel, true)
+ })
+
+ t.Run("Moderation disabled, user isn't admin", func(t *testing.T) {
+ defer tests.PrintCurrentTest(t)()
+ defer test.MockVariableValue(&setting.Moderation.Enabled, false)()
+
+ session := loginUser(t, "user2")
+ // The /admin/... link is not advertized to non-admins
+
+ // Can report anyone
+ page := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "/user1"), http.StatusOK).Body)
+ page.AssertElement(t, reportSel, false)
+ page.AssertElement(t, admSel, false)
+
+ page = NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", "/user2"), http.StatusOK).Body)
+ page.AssertElement(t, reportSel, false)
+ page.AssertElement(t, admSel, false)
+ })
+}
diff --git a/web_src/css/modules/dropdown.css b/web_src/css/modules/dropdown.css
index fceba38d73..80625461f8 100644
--- a/web_src/css/modules/dropdown.css
+++ b/web_src/css/modules/dropdown.css
@@ -7,6 +7,9 @@
* NoJS mode could be improved by forcing the same [name] onto all dropdowns, so
* that the browser will automatically close all but the one that was just opened
* using keyboard. But the code doing that will not be as clean.
+ *
+ * Note: when implementing this dropdown, please use `dropdown` as the 1st class,
+ * so it is possible to search for all dropdowns with `details class="dropdown`
*/
:root details.dropdown {
@@ -26,6 +29,7 @@ details.dropdown {
position: relative;
}
+/* Opener */
details.dropdown > summary {
/* Optional flex+gap in case summary contains multiple elements */
display: flex;
@@ -39,25 +43,17 @@ details.dropdown > summary {
user-select: none;
list-style-type: none;
+ /* Display a border around opener */
&.border {
border: 1px solid var(--color-light-border);
}
+ /* Increase inline padding - for openers with text, like filter menus */
&.options {
padding-inline: 0.75rem;
}
}
-details.dropdown > summary:hover,
-details.dropdown > summary + ul > li:hover {
- background: var(--color-hover);
-}
-
-details.dropdown[open] > summary,
-details.dropdown > summary + ul > li:focus-within {
- background: var(--color-active);
-}
-
-/* NoJS mode. Creates a virtual fullscreen area. Clicking it closes the dropdown. */
+/* NoJS mode: create a virtual fullscreen area which closes the dropdown when clicked on */
.no-js details.dropdown[open] > summary::before {
z-index: 1;
position: fixed;
@@ -69,23 +65,52 @@ details.dropdown > summary + ul > li:focus-within {
cursor: default;
}
-details.dropdown > summary + ul {
+details.dropdown > summary:hover,
+details.dropdown > .content > ul > li:hover {
+ background: var(--color-hover);
+}
+
+details.dropdown[open] > summary,
+details.dropdown > .content > ul > li:focus-within {
+ background: var(--color-active);
+}
+
+details.dropdown > .content {
z-index: 99;
position: absolute;
min-width: max-content;
- margin: 0;
- margin-top: 0.5rem;
- padding: 0;
- display: flex;
- flex-direction: column;
- list-style-type: none;
border-radius: var(--border-radius);
background: var(--color-body);
box-shadow: var(--dropdown-box-shadow);
border: 1px solid var(--color-secondary);
+ margin-top: 0.5rem;
+
+ /* ToDo: upstream to base.css, remove from normalize.css */
+ > hr {
+ height: 1px;
+ margin-block: 0.25rem;
+ background-color: var(--color-secondary);
+ }
}
-details.dropdown > summary + ul > li {
+details.dropdown > .content > ul {
+ /* Suppress default styling of
*/
+ margin: 0;
+ padding: 0;
+ list-style-type: none;
+
+ /* Round first item of first list and last item of last list. Each of these
+ * selectors should only resolve to one element in any .content */
+ &:first-of-type > li:first-child {
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
+ }
+ &:last-of-type > li:last-child {
+ border-radius: 0 0 var(--border-radius) var(--border-radius);
+ }
+}
+
+/* General styling of list items */
+details.dropdown > .content > ul > li {
width: 100%;
background: none;
@@ -101,42 +126,43 @@ details.dropdown > summary + ul > li {
/* Suppress underline - hover is indicated by background color */
text-decoration: none;
+ /* Items that are pre-selected in template or by JS */
&.active {
background: var(--color-active);
font-weight: var(--font-weight-medium);
}
}
- /* Cancel default styling of button elements */
+
+ /* Suppress default styling of