diff --git a/templates/shared/user/actions_menu.tmpl b/templates/shared/user/actions_menu.tmpl
new file mode 100644
index 0000000000..4095ada6d9
--- /dev/null
+++ b/templates/shared/user/actions_menu.tmpl
@@ -0,0 +1,47 @@
+
diff --git a/templates/shared/user/profile_big_avatar.tmpl b/templates/shared/user/profile_big_avatar.tmpl
index 81ac6b78ce..ca3a5a2076 100644
--- a/templates/shared/user/profile_big_avatar.tmpl
+++ b/templates/shared/user/profile_big_avatar.tmpl
@@ -1,6 +1,9 @@
{{if .IsHTMX}}
{{template "base/alert" .}}
{{end}}
+
+{{$showFollow := and .IsSigned (ne .SignedUserID .ContextUser.ID)}}
+
{{if eq .SignedUserID .ContextUser.ID}}
@@ -16,18 +19,32 @@
{{if .ContextUser.FullName}}{{end}}
-
{{.ContextUser.Name}} {{if .ContextUser.GetPronouns .IsSigned}} · {{.ContextUser.GetPronouns .IsSigned}}{{end}} {{if .IsAdmin}}
-
- {{svg "octicon-gear" 18}}
-
- {{end}}
-
+ {{if $showFollow}}
+
+
+ {{if .IsFollowing}}
+
+ {{else}}
+
+ {{end}}
+
+ {{template "shared/user/actions_menu" .}}
+
+ {{end}}
diff --git a/tests/e2e/README.md b/tests/e2e/README.md
index 35fc5e7d1d..d70bf399a5 100644
--- a/tests/e2e/README.md
+++ b/tests/e2e/README.md
@@ -364,7 +364,7 @@ the click will succeed,
but the depending interaction won't,
although playwright repeatedly tries to find the content.
-You can [group statements using toPass]()https://playwright.dev/docs/test-assertions#expecttopass).
+You can [group statements using toPass](https://playwright.dev/docs/test-assertions#expecttopass).
This code retries the dropdown click until the second item is found.
~~~js
diff --git a/tests/e2e/dimmer.test.e2e.ts b/tests/e2e/dimmer.test.e2e.ts
index 9ee6f82c07..48084b0e52 100644
--- a/tests/e2e/dimmer.test.e2e.ts
+++ b/tests/e2e/dimmer.test.e2e.ts
@@ -12,12 +12,13 @@ test.use({user: 'user2'});
test('Dimmed modal', async ({page}) => {
await page.goto('/user1');
- await expect(page.locator('.block')).toContainText('Block');
+ await expect(page.locator('#action-block')).toContainText('Block');
// Ensure the modal is hidden
await expect(page.locator('#block-user')).toBeHidden();
- await page.locator('.block').click();
+ await page.locator('.actions .dropdown').click();
+ await page.locator('#action-block').click();
// Modal and dimmer should be visible.
await expect(page.locator('#block-user')).toBeVisible();
@@ -31,7 +32,8 @@ test('Dimmed modal', async ({page}) => {
await save_visual(page);
// Open the block modal and make the dimmer visible again.
- await page.locator('.block').click();
+ await page.locator('.actions .dropdown').click();
+ await page.locator('#action-block').click();
await expect(page.locator('#block-user')).toBeVisible();
await expect(page.locator('.ui.dimmer')).toBeVisible();
await expect(page.locator('.ui.dimmer')).toHaveCount(1);
diff --git a/tests/e2e/dropdown.test.e2e.ts b/tests/e2e/dropdown.test.e2e.ts
new file mode 100644
index 0000000000..5f226f94bb
--- /dev/null
+++ b/tests/e2e/dropdown.test.e2e.ts
@@ -0,0 +1,106 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// @watch start
+// templates/shared/user/**
+// web_src/js/modules/dropdown.ts
+// @watch end
+
+import {expect} from '@playwright/test';
+import {test} from './utils_e2e.ts';
+
+test('JS enhanced', async ({page}) => {
+ await page.goto('/user1');
+
+ await expect(page.locator('body')).not.toContainClass('no-js');
+ const nojsNotice = page.locator('body .full noscript');
+ await expect(nojsNotice).toBeHidden();
+
+ // Open and close by clicking summary
+ const dropdownSummary = page.locator('details.dropdown summary');
+ const dropdownContent = page.locator('details.dropdown ul');
+ await expect(dropdownContent).toBeHidden();
+ await dropdownSummary.click();
+ await expect(dropdownContent).toBeVisible();
+ await dropdownSummary.click();
+ await expect(dropdownContent).toBeHidden();
+
+ // Close by clicking elsewhere
+ const elsewhere = page.locator('.username');
+ await expect(dropdownContent).toBeHidden();
+ await dropdownSummary.click();
+ await expect(dropdownContent).toBeVisible();
+ await elsewhere.click();
+ await expect(dropdownContent).toBeHidden();
+
+ // Open and close with keypressing
+ await dropdownSummary.focus();
+ await dropdownSummary.press(`Enter`);
+ await expect(dropdownContent).toBeVisible();
+ await dropdownSummary.press(`Space`);
+ await expect(dropdownContent).toBeHidden();
+
+ await dropdownSummary.press(`Space`);
+ await expect(dropdownContent).toBeVisible();
+ await dropdownSummary.press(`Enter`);
+ await expect(dropdownContent).toBeHidden();
+
+ await dropdownSummary.press(`Enter`);
+ await expect(dropdownContent).toBeVisible();
+ await dropdownSummary.press(`Escape`);
+ await expect(dropdownContent).toBeHidden();
+
+ // Open and close by opening a different dropdown
+ const languageMenu = page.locator('.language-menu');
+ await dropdownSummary.click();
+ await expect(dropdownContent).toBeVisible();
+ await expect(languageMenu).toBeHidden();
+ await page.locator('.language.dropdown').click();
+ await expect(dropdownContent).toBeHidden();
+ await expect(languageMenu).toBeVisible();
+});
+
+test('No JS', async ({browser}) => {
+ const context = await browser.newContext({javaScriptEnabled: false});
+ const nojsPage = await context.newPage();
+ await nojsPage.goto('/user1');
+
+ const nojsNotice = nojsPage.locator('body .full noscript');
+ await expect(nojsNotice).toBeVisible();
+ 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');
+ await expect(dropdownContent).toBeHidden();
+ await dropdownSummary.click();
+ await expect(dropdownContent).toBeVisible();
+ await dropdownSummary.click();
+ await expect(dropdownContent).toBeHidden();
+
+ // Close by clicking elsewhere (by hitting ::before with increased z-index)
+ const elsewhere = nojsPage.locator('#navbar');
+ await expect(dropdownContent).toBeHidden();
+ await dropdownSummary.click();
+ await expect(dropdownContent).toBeVisible();
+ // eslint-disable-next-line playwright/no-force-option
+ await elsewhere.click({force: true});
+ await expect(dropdownContent).toBeHidden();
+
+ // Open and close with keypressing
+ await dropdownSummary.press(`Enter`);
+ await expect(dropdownContent).toBeVisible();
+ await dropdownSummary.press(`Space`);
+ await expect(dropdownContent).toBeHidden();
+
+ await dropdownSummary.press(`Space`);
+ await expect(dropdownContent).toBeVisible();
+ await dropdownSummary.press(`Enter`);
+ await expect(dropdownContent).toBeHidden();
+
+ // Escape is not usable w/o JS enhancements
+ await dropdownSummary.press(`Enter`);
+ await expect(dropdownContent).toBeVisible();
+ await dropdownSummary.press(`Escape`);
+ await expect(dropdownContent).toBeVisible();
+});
diff --git a/tests/e2e/profile_actions.test.e2e.ts b/tests/e2e/profile_actions.test.e2e.ts
index a66dc43aab..e27ecf64cf 100644
--- a/tests/e2e/profile_actions.test.e2e.ts
+++ b/tests/e2e/profile_actions.test.e2e.ts
@@ -2,6 +2,7 @@
// routers/web/user/**
// templates/shared/user/**
// web_src/js/features/common-global.js
+// web_src/js/modules/dropdown.ts
// @watch end
import {expect} from '@playwright/test';
@@ -9,13 +10,11 @@ import {save_visual, test} from './utils_e2e.ts';
test.use({user: 'user2'});
-test('Follow actions', async ({page}) => {
+test('Follow and block actions', async ({page}) => {
await page.goto('/user1');
// Check if following and then unfollowing works.
- // This checks that the event listeners of
- // the buttons aren't disappearing.
- const followButton = page.locator('.follow');
+ const followButton = page.locator('.primary-action button');
await expect(followButton).toContainText('Follow');
await followButton.click();
await expect(followButton).toContainText('Unfollow');
@@ -23,13 +22,19 @@ test('Follow actions', async ({page}) => {
await expect(followButton).toContainText('Follow');
// Simple block interaction.
- await expect(page.locator('.block')).toContainText('Block');
+ const actionsDropdownBtn = page.locator('.actions .dropdown summary');
+ const blockButton = page.locator('#action-block');
+ await expect(blockButton).toBeHidden();
- await page.locator('.block').click();
+ await actionsDropdownBtn.click();
+ await expect(blockButton).toBeVisible();
+ await expect(blockButton).toContainText('Block');
+
+ await blockButton.click();
await expect(page.locator('#block-user')).toBeVisible();
await save_visual(page);
await page.locator('#block-user .ok').click();
- await expect(page.locator('.block')).toContainText('Unblock');
+ await expect(blockButton).toContainText('Unblock');
await expect(page.locator('#block-user')).toBeHidden();
// Check that following the user yields in a error being shown.
@@ -40,6 +45,7 @@ test('Follow actions', async ({page}) => {
await save_visual(page);
// Unblock interaction.
- await page.locator('.block').click();
- await expect(page.locator('.block')).toContainText('Block');
+ await actionsDropdownBtn.click();
+ await blockButton.click();
+ await expect(blockButton).toContainText('Block');
});
diff --git a/tests/integration/user_profile_activity_test.go b/tests/integration/user_profile_attributes_test.go
similarity index 76%
rename from tests/integration/user_profile_activity_test.go
rename to tests/integration/user_profile_attributes_test.go
index 47a8df94b2..15bf173922 100644
--- a/tests/integration/user_profile_activity_test.go
+++ b/tests/integration/user_profile_attributes_test.go
@@ -16,15 +16,15 @@ import (
"github.com/stretchr/testify/assert"
)
-// TestUserProfileActivity ensures visibility and correctness of elements related to activity of a user:
-// - RSS feed button (doesn't test `other.ENABLE_FEED:false`)
+// TestUserProfileAttributes ensures visibility and correctness of elements related to activity of a user:
+// - RSS/atom feed links (doesn't test `other.ENABLE_FEED:false`) and a few other links nearby
// - Public activity tab
// - Banner/hint in the tab
// - "Configure" link in the hint
// These elements might depend on the following:
// - Profile visibility
// - Public activity visibility
-func TestUserProfileActivity(t *testing.T) {
+func TestUserProfileAttributes(t *testing.T) {
defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
defer tests.PrepareTestEnv(t)()
// This test needs multiple users with different access statuses to check for all possible states
@@ -38,10 +38,10 @@ func TestUserProfileActivity(t *testing.T) {
// Set activity visibility of user2 to public. This is the default, but won't hurt to set it before testing.
testChangeUserActivityVisibility(t, userRegular, "off")
- // Verify availability of RSS button and activity tab
- testUser2ActivityButtonsAvailability(t, userAdmin, true)
- testUser2ActivityButtonsAvailability(t, userRegular, true)
- testUser2ActivityButtonsAvailability(t, userGuest, true)
+ // Verify availability of activity tab and other links
+ testUser2ActivityLinksAvailability(t, userAdmin, true, true, false)
+ testUser2ActivityLinksAvailability(t, userRegular, true, false, true)
+ testUser2ActivityLinksAvailability(t, userGuest, true, false, false)
// Verify the hint for all types of users: admin, self, guest
testUser2ActivityVisibility(t, userAdmin, "This activity is visible to everyone, but as an administrator you can also see interactions in private spaces.", true)
@@ -63,15 +63,15 @@ func TestUserProfileActivity(t *testing.T) {
// Set profile visibility of user2 back to public
testChangeUserProfileVisibility(t, userRegular, structs.VisibleTypePublic)
- // = Private acitivty =
+ // = Private activity =
// Set activity visibility of user2 to private
testChangeUserActivityVisibility(t, userRegular, "on")
- // Verify availability of RSS button and activity tab
- testUser2ActivityButtonsAvailability(t, userAdmin, true)
- testUser2ActivityButtonsAvailability(t, userRegular, true)
- testUser2ActivityButtonsAvailability(t, userGuest, false)
+ // Verify availability of activity tab and other links
+ testUser2ActivityLinksAvailability(t, userAdmin, true, true, false)
+ testUser2ActivityLinksAvailability(t, userRegular, true, false, true)
+ testUser2ActivityLinksAvailability(t, userGuest, false, false, false)
// Verify the hint for all types of users: admin, self, guest
testUser2ActivityVisibility(t, userAdmin, "This activity is visible to you because you're an administrator, but the user wants it to remain private.", true)
@@ -112,10 +112,7 @@ func testUser2ActivityVisibility(t *testing.T, session *TestSession, hint string
hintLink, hintLinkExists := page.Find("#visibility-hint a").Attr("href")
// Check that the hint aligns with the actual feed availability
- assert.Equal(t, availability, page.Find("#activity-feed").Length() > 0)
-
- // Check availability of RSS feed button too
- assert.Equal(t, availability, page.Find("#profile-avatar-card a[href='/sub/user2.rss']").Length() > 0)
+ page.AssertElement(t, "#activity-feed", availability)
// Check that the current tab is displayed and is active regardless of it's actual availability
// For example, on /
it wouldn't be available to guest, but it should be still present on /?tab=activity
@@ -126,10 +123,21 @@ func testUser2ActivityVisibility(t *testing.T, session *TestSession, hint string
return ""
}
-// testUser2ActivityButtonsAvailability checks visibility of Public activity tab on main profile page
-func testUser2ActivityButtonsAvailability(t *testing.T, session *TestSession, buttons bool) {
+// testUser2ActivityLinksAvailability checks visibility of:
+// * Public activity tab on main profile page
+// * user details, profile edit, feed links
+func testUser2ActivityLinksAvailability(t *testing.T, session *TestSession, activity, adminLink, editLink bool) {
t.Helper()
response := session.MakeRequest(t, NewRequest(t, "GET", "/user2"), http.StatusOK)
page := NewHTMLParser(t, response.Body)
- assert.Equal(t, buttons, page.Find("overflow-menu .item[href='/sub/user2?tab=activity']").Length() > 0)
+ page.AssertElement(t, "overflow-menu .item[href='/sub/user2?tab=activity']", activity)
+
+ // User details - for admins only
+ page.AssertElement(t, "#profile-avatar-card a[href='/sub/admin/users/2']", adminLink)
+ // Edit profile - for self only
+ page.AssertElement(t, "#profile-avatar-card a[href='/sub/user/settings']", editLink)
+
+ // Feed links
+ page.AssertElement(t, "#profile-avatar-card a[href='/sub/user2.rss']", activity)
+ page.AssertElement(t, "#profile-avatar-card a[href='/sub/user2.atom']", activity)
}
diff --git a/web_src/css/index.css b/web_src/css/index.css
index 0e9f2b173a..e7e5dda2d5 100644
--- a/web_src/css/index.css
+++ b/web_src/css/index.css
@@ -19,6 +19,7 @@
@import "./modules/dimmer.css";
@import "./modules/switch.css";
+@import "./modules/dropdown.css";
@import "./modules/select.css";
@import "./modules/tippy.css";
@import "./modules/breadcrumb.css";
diff --git a/web_src/css/modules/dropdown.css b/web_src/css/modules/dropdown.css
new file mode 100644
index 0000000000..66762ac45c
--- /dev/null
+++ b/web_src/css/modules/dropdown.css
@@ -0,0 +1,118 @@
+/* This is an implementation of a dropdown menu based on details HTML tag.
+ * It is inspired by https://picocss.com/docs/dropdown.
+ *
+ * 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.
+*/
+
+:root details.dropdown {
+ --dropdown-box-shadow: 0 6px 18px var(--color-shadow);
+ --dropdown-item-padding: 0.5rem 0.75rem;
+}
+
+@media (pointer: coarse) {
+ :root details.dropdown {
+ --dropdown-item-padding: 0.75rem 1rem;
+ }
+}
+
+details.dropdown {
+ position: relative;
+}
+
+details.dropdown > summary {
+ /* Optional flex+gap in case summary contains multiple elements */
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ /* Cancel some of default styling */
+ user-select: none;
+ list-style-type: none;
+ /* Main visual properties */
+ border-radius: var(--border-radius);
+ padding: 0.5rem;
+}
+
+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. */
+.no-js details.dropdown[open] > summary::before {
+ z-index: 1;
+ position: fixed;
+ width: 100vw;
+ height: 100vh;
+ inset: 0;
+ background: 0 0;
+ content: "";
+ cursor: default;
+}
+
+details.dropdown > summary + ul {
+ 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);
+}
+
+details.dropdown > summary + ul > li {
+ width: 100%;
+ background: none;
+}
+
+details.dropdown > summary + ul > li:first-child {
+ border-radius: var(--border-radius) var(--border-radius) 0 0;
+}
+
+details.dropdown > summary + ul > li:last-child {
+ border-radius: 0 0 var(--border-radius) var(--border-radius);
+}
+
+/* dir-auto option - switch the direction at a width point where most of layout changes occur. */
+/* There's no way to check with CSS if LTR dropdown will fit on screen without JS. */
+@media (max-width: 767.98px) {
+ details.dropdown.dir-auto > summary + ul {
+ inset-inline: 0 auto;
+ direction: rtl;
+ }
+ details.dropdown.dir-auto > summary + ul > li {
+ direction: ltr;
+ }
+}
+/* Note: https://css-tricks.com/css-anchor-positioning-guide/
+* looks like a great thing but FF still doesn't support it. */
+
+/* Note: dropdown.dir-rtl can be implemented when needed, e.g. for navbar profile dropdown on desktop layout. */
+
+details.dropdown > summary + ul > li > .item {
+ padding: var(--dropdown-item-padding);
+ width: 100%;
+ display: flex;
+ gap: 0.75rem;
+ align-items: center;
+ color: var(--color-text);
+ /* Suppress underline - hover is indicated by background color */
+ text-decoration: none;
+}
+
+/* Cancel default styling of button elements */
+details.dropdown > summary + ul > li button {
+ background: none;
+}
diff --git a/web_src/css/user.css b/web_src/css/user.css
index b554f4e0b1..7fa81670fb 100644
--- a/web_src/css/user.css
+++ b/web_src/css/user.css
@@ -21,25 +21,26 @@
}
.user.profile .ui.card .extra.content > ul > li {
- padding: 10px;
display: flex;
+ padding: 0.75rem;
+ gap: 0.5rem;
list-style: none;
align-items: center;
- gap: 0.25em;
}
.user.profile .ui.card .extra.content > ul > li:not(:last-child) {
border-bottom: 1px solid var(--color-secondary);
}
-.user.profile .ui.card .extra.content > ul > li .svg {
- margin-left: 1px;
- margin-right: 5px;
+.user.profile .ui.card .actions {
+ padding: 0.75rem;
+ display: grid;
+ grid-template-columns: 1fr auto;
+ align-items: center;
+ gap: 0.75rem;
}
-.user.profile .ui.card .extra.content > ul > li.follow .ui.button,
-.user.profile .ui.card .extra.content > ul > li.block .ui.button,
-.user.profile .ui.card .extra.content > ul > li.report .ui.button {
+.user.profile .ui.card .primary-action .ui.button {
align-items: center;
display: flex;
justify-content: center;
diff --git a/web_src/js/index.js b/web_src/js/index.js
index f1fed9d2f8..1dab9ae292 100644
--- a/web_src/js/index.js
+++ b/web_src/js/index.js
@@ -75,6 +75,7 @@ import {initCopyContent} from './features/copycontent.js';
import {initCaptcha} from './features/captcha.js';
import {initRepositoryActionView} from './components/RepoActionView.vue';
import {initGlobalTooltips} from './modules/tippy.js';
+import {initDropdowns} from './modules/dropdown.ts';
import {initGiteaFomantic} from './modules/fomantic.js';
import {onDomReady} from './utils/dom.js';
import {initRepoIssueList} from './features/repo-issue-list.js';
@@ -103,6 +104,7 @@ onDomReady(() => {
initGlobalEnterQuickSubmit();
initGlobalFormDirtyLeaveConfirm();
initGlobalLinkActions();
+ initDropdowns();
initCommonOrganization();
initCommonIssueListQuickGoto();
@@ -191,4 +193,7 @@ onDomReady(() => {
initGltfViewer();
initScopedAccessTokenCategories();
initColorPickers();
+
+ // Deactivate CSS-only noJS usability supplements
+ document.body.classList.remove('no-js');
});
diff --git a/web_src/js/modules/dropdown.ts b/web_src/js/modules/dropdown.ts
new file mode 100644
index 0000000000..0731eeb86f
--- /dev/null
+++ b/web_src/js/modules/dropdown.ts
@@ -0,0 +1,35 @@
+// Copyright 2025 The Forgejo Authors. All rights reserved.
+// SPDX-License-Identifier: GPL-3.0-or-later
+
+// Details can be opened by clicking summary or by pressing Space or Enter while
+// being focused on summary. But without JS options for closing it are limited.
+// Event listeners in this file provide more convenient options for that:
+// click iteration with anything on the page and pressing Escape.
+
+export function initDropdowns() {
+ document.addEventListener('click', (event) => {
+ const dropdown = document.querySelector('details.dropdown[open]');
+ // No open dropdowns on page, nothing to do.
+ if (dropdown === null) return;
+
+ const target = event.target as HTMLElement;
+ // User clicked something in the open dropdown, don't interfere.
+ if (dropdown.contains(target)) return;
+
+ // User clicked something that isn't the open dropdown, so close it.
+ dropdown.removeAttribute('open');
+ });
+
+ // Close open dropdowns on Escape press
+ document.addEventListener('keydown', (event) => {
+ // This press wasn't escape, nothing to do.
+ if (event.key !== 'Escape') return;
+
+ const dropdown = document.querySelector('details.dropdown[open]');
+ // No open dropdowns on page, nothing to do.
+ if (dropdown === null) return;
+
+ // User pressed Escape while having an open dropdown, probably wants it be closed.
+ dropdown.removeAttribute('open');
+ });
+}