feat(ui): redesign user profile actions layout (#7906)

Related: https://codeberg.org/forgejo/forgejo/pulls/3950#issue-785253, https://codeberg.org/forgejo/forgejo/pulls/3950#issuecomment-1998551.

## Links in dropdown

* move _admin only_ User details link here, give it always-visible text
* add new _self only_ Edit profile link here
* move RSS feed link here
* add new Atom feed link here, previously unadvertised
* add new SSH keys link here (`.keys`), previously unadvertised
* add new GPG keys link here (`.gpg`), previously unadvertised
* move Block/Unblock button here
* move Report abuse link here

If primary action is available (Follow/Unfollow), dropdown with more actions goes after it. If not, it is in line with followers, in place where RSS feed button used to be.

## New dropdown

Related: https://codeberg.org/forgejo/design/issues/23, https://codeberg.org/forgejo/forgejo/issues/3853, https://codeberg.org/0ko/forgejo/issues/2.

Implemented a new dropdown: noJS-usable, JS-enhanced for better keyboard navigation and a11y.
Styling is mostly same as the existing ones have, but row density depends on `@media` pointer type.

My choice of CSS properties have been influenced of these:
* 72a3adb16b
* 51dd2293ca

Inspired-by: KiranMantha <kiranv.mantha@gmail.com>
Inspired-by: Lucas Larroche <lucas@larroche.com>
Co-authored-by: Beowulf <beowulf@beocode.eu>
Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7906
Reviewed-by: Otto <otto@codeberg.org>
Reviewed-by: Beowulf <beowulf@beocode.eu>
Co-authored-by: 0ko <0ko@noreply.codeberg.org>
Co-committed-by: 0ko <0ko@noreply.codeberg.org>
This commit is contained in:
0ko 2025-06-24 14:16:51 +02:00 committed by Beowulf
parent f7d7d67238
commit 7086e7a9ac
15 changed files with 417 additions and 91 deletions

View file

@ -10,6 +10,7 @@
# Javascript and CSS code. # Javascript and CSS code.
web_src/.* @beowulf @gusted web_src/.* @beowulf @gusted
web_src/css/.* @0ko
# HTML templates used by the backend. # HTML templates used by the backend.
templates/.* @beowulf @gusted templates/.* @beowulf @gusted

View file

@ -63,6 +63,11 @@
"alert.asset_load_failed": "Failed to load asset files from {path}. Please make sure the asset files can be accessed.", "alert.asset_load_failed": "Failed to load asset files from {path}. Please make sure the asset files can be accessed.",
"alert.range_error": " must be a number between %[1]s and %[2]s.", "alert.range_error": " must be a number between %[1]s and %[2]s.",
"install.invalid_lfs_path": "Unable to create the LFS root at the specified path: %[1]s", "install.invalid_lfs_path": "Unable to create the LFS root at the specified path: %[1]s",
"profile.actions.tooltip": "More actions",
"profile.edit.link": "Edit profile",
"feed.atom.link": "Atom feed",
"keys.ssh.link": "SSH keys",
"keys.gpg.link": "GPG keys",
"admin.config.moderation_config": "Moderation configuration", "admin.config.moderation_config": "Moderation configuration",
"moderation.report_abuse": "Report abuse", "moderation.report_abuse": "Report abuse",
"moderation.report_content": "Report content", "moderation.report_content": "Report content",

View file

@ -25,7 +25,7 @@
{{template "base/head_style" .}} {{template "base/head_style" .}}
{{template "custom/header" .}} {{template "custom/header" .}}
</head> </head>
<body hx-headers='{"x-csrf-token": "{{.CsrfToken}}"}' hx-swap="outerHTML" hx-ext="morph" hx-push-url="false"> <body class="no-js" hx-headers='{"x-csrf-token": "{{.CsrfToken}}"}' hx-swap="outerHTML" hx-ext="morph" hx-push-url="false">
{{template "custom/body_outer_pre" .}} {{template "custom/body_outer_pre" .}}
<div class="full height"> <div class="full height">

View file

@ -0,0 +1,47 @@
<details class="dropdown dir-auto">
<summary data-tooltip-content="{{ctx.Locale.Tr "profile.actions.tooltip"}}">{{svg "octicon-kebab-horizontal"}}</summary>
<ul>
{{if eq .SignedUserID .ContextUser.ID}}
<li>
<a href="{{AppSubUrl}}/user/settings" class="item">{{svg "octicon-pencil"}}{{ctx.Locale.Tr "profile.edit.link"}}</a>
</li>
{{end}}
{{if .IsAdmin}}
<li>
<a href="{{AppSubUrl}}/admin/users/{{.ContextUser.ID}}" class="item">{{svg "octicon-gear"}}{{ctx.Locale.Tr "admin.users.details"}}</a>
</li>
{{end}}
{{if and .EnableFeed (or .IsAdmin (eq .SignedUserID .ContextUser.ID) (not .ContextUser.KeepActivityPrivate))}}
<li>
<a href="{{.ContextUser.HomeLink}}.rss" class="item">{{svg "octicon-rss"}}{{ctx.Locale.Tr "rss_feed"}}</a>
</li>
<li>
<a href="{{.ContextUser.HomeLink}}.atom" class="item">{{svg "octicon-rss"}}{{ctx.Locale.Tr "feed.atom.link"}}</a>
</li>
{{end}}
<li>
<a href="{{.ContextUser.HomeLink}}.keys" class="item">{{svg "octicon-key"}}{{ctx.Locale.Tr "keys.ssh.link"}}</a>
</li>
<li>
<a href="{{.ContextUser.HomeLink}}.gpg" class="item">{{svg "octicon-key"}}{{ctx.Locale.Tr "keys.gpg.link"}}</a>
</li>
{{if and .IsSigned (ne .SignedUserID .ContextUser.ID)}}
<li hx-target="#profile-avatar-card" hx-indicator="#profile-avatar-card" id="action-block">
{{if .IsBlocked}}
<button class="item orange text" hx-post="{{.ContextUser.HomeLink}}?action=unblock">
{{svg "octicon-person"}} {{ctx.Locale.Tr "user.unblock"}}
</button>
{{else}}
<button class="item orange text" data-modal-id="block-user" hx-post="{{.ContextUser.HomeLink}}?action=block" hx-confirm="-">
{{svg "octicon-blocked"}} {{ctx.Locale.Tr "user.block"}}
</button>
{{end}}
</li>
{{end}}
{{if and .IsModerationEnabled .IsSigned (ne .SignedUserID .ContextUser.ID)}}
<li>
<a href="{{AppSubUrl}}/report_abuse?type=user&id={{.ContextUser.ID}}" class="item orange text">{{svg "octicon-stop"}}{{ctx.Locale.Tr "moderation.report_abuse"}}</a>
</li>
{{end}}
</ul>
</details>

View file

@ -1,6 +1,9 @@
{{if .IsHTMX}} {{if .IsHTMX}}
{{template "base/alert" .}} {{template "base/alert" .}}
{{end}} {{end}}
{{$showFollow := and .IsSigned (ne .SignedUserID .ContextUser.ID)}}
<div id="profile-avatar-card" class="ui card" hx-swap="morph"> <div id="profile-avatar-card" class="ui card" hx-swap="morph">
<div id="profile-avatar" class="content tw-flex"> <div id="profile-avatar" class="content tw-flex">
{{if eq .SignedUserID .ContextUser.ID}} {{if eq .SignedUserID .ContextUser.ID}}
@ -16,18 +19,32 @@
</div> </div>
<div class="content tw-break-anywhere profile-avatar-name"> <div class="content tw-break-anywhere profile-avatar-name">
{{if .ContextUser.FullName}}<span class="header text center">{{.ContextUser.FullName}}</span>{{end}} {{if .ContextUser.FullName}}<span class="header text center">{{.ContextUser.FullName}}</span>{{end}}
<span class="username text center">{{.ContextUser.Name}} {{if .ContextUser.GetPronouns .IsSigned}} · {{.ContextUser.GetPronouns .IsSigned}}{{end}} {{if .IsAdmin}} <span class="username">{{.ContextUser.Name}} {{if .ContextUser.GetPronouns .IsSigned}} · {{.ContextUser.GetPronouns .IsSigned}}{{end}}</span>
<a class="muted" href="{{AppSubUrl}}/admin/users/{{.ContextUser.ID}}" data-tooltip-content="{{ctx.Locale.Tr "admin.users.details"}}"> <div class="tw-mt-2 tw-flex tw-items-center tw-gap-2 tw-justify-center">
{{svg "octicon-gear" 18}} <span>
</a> <a class="muted" href="{{.ContextUser.HomeLink}}?tab=followers">{{svg "octicon-people" 18 "tw-mr-1"}}{{ctx.Locale.TrN .NumFollowers "user.followers_one" "user.followers_few" .NumFollowers}}</a> · <a class="muted" href="{{.ContextUser.HomeLink}}?tab=following">{{ctx.Locale.TrN .NumFollowing "user.following_one" "user.following_few" .NumFollowing}}</a>
{{end}}</span> </span>
<div class="tw-mt-2"> {{if not $showFollow}}
<a class="muted" href="{{.ContextUser.HomeLink}}?tab=followers">{{svg "octicon-people" 18 "tw-mr-1"}}{{ctx.Locale.TrN .NumFollowers "user.followers_one" "user.followers_few" .NumFollowers}}</a> · <a class="muted" href="{{.ContextUser.HomeLink}}?tab=following">{{ctx.Locale.TrN .NumFollowing "user.following_one" "user.following_few" .NumFollowing}}</a> {{template "shared/user/actions_menu" .}}
{{if and .EnableFeed (or .IsAdmin (eq .SignedUserID .ContextUser.ID) (not .ContextUser.KeepActivityPrivate))}}
<a href="{{.ContextUser.HomeLink}}.rss"><i class="ui text grey tw-ml-2" data-tooltip-content="{{ctx.Locale.Tr "rss_feed"}}">{{svg "octicon-rss" 18}}</i></a>
{{end}} {{end}}
</div> </div>
</div> </div>
{{if $showFollow}}
<div class="actions">
<div class="primary-action" hx-target="#profile-avatar-card" hx-indicator="#profile-avatar-card">
{{if .IsFollowing}}
<button hx-post="{{.ContextUser.HomeLink}}?action=unfollow" class="ui basic red button tw-flex tw-gap-1">
{{svg "octicon-person"}} {{ctx.Locale.Tr "user.unfollow"}}
</button>
{{else}}
<button hx-post="{{.ContextUser.HomeLink}}?action=follow" class="ui basic primary button tw-flex tw-gap-1">
{{svg "octicon-person"}} {{ctx.Locale.Tr "user.follow"}}
</button>
{{end}}
</div>
{{template "shared/user/actions_menu" .}}
</div>
{{end}}
<div class="extra content tw-break-anywhere"> <div class="extra content tw-break-anywhere">
<ul> <ul>
{{if .ContextUser.Location}} {{if .ContextUser.Location}}
@ -42,17 +59,17 @@
</li> </li>
{{end}} {{end}}
{{if .ShowUserEmail}} {{if .ShowUserEmail}}
<li> <li>
{{svg "octicon-mail"}} {{svg "octicon-mail"}}
<a class="tw-flex-1" href="mailto:{{.ContextUser.Email}}" rel="nofollow">{{.ContextUser.Email}}</a> <a class="tw-flex-1" href="mailto:{{.ContextUser.Email}}" rel="nofollow">{{.ContextUser.Email}}</a>
{{if (eq .SignedUserID .ContextUser.ID)}} {{if (eq .SignedUserID .ContextUser.ID)}}
<a href="{{AppSubUrl}}/user/settings#privacy-user-settings"> <a href="{{AppSubUrl}}/user/settings#privacy-user-settings">
<i data-tooltip-content="{{ctx.Locale.Tr "user.email_visibility.limited"}}"> <i data-tooltip-content="{{ctx.Locale.Tr "user.email_visibility.limited"}}">
{{svg "octicon-unlock"}} {{svg "octicon-unlock"}}
</i> </i>
</a> </a>
{{end}} {{end}}
</li> </li>
{{end}} {{end}}
{{if .ContextUser.Website}} {{if .ContextUser.Website}}
<li> <li>
@ -73,7 +90,10 @@
</li> </li>
{{end}} {{end}}
{{end}} {{end}}
<li>{{svg "octicon-calendar"}} <span>{{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .ContextUser.CreatedUnix)}}</span></li> <li>
{{svg "octicon-calendar"}}
<span>{{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .ContextUser.CreatedUnix)}}</span>
</li>
{{if and .Orgs .HasOrgsVisible}} {{if and .Orgs .HasOrgsVisible}}
<li> <li>
<ul class="user-orgs"> <ul class="user-orgs">
@ -100,35 +120,6 @@
</ul> </ul>
</li> </li>
{{end}} {{end}}
{{if and .IsSigned (ne .SignedUserID .ContextUser.ID)}}
<li class="follow" hx-target="#profile-avatar-card" hx-indicator="#profile-avatar-card">
{{if $.IsFollowing}}
<button hx-post="{{.ContextUser.HomeLink}}?action=unfollow" class="ui basic red button">
{{svg "octicon-person"}} {{ctx.Locale.Tr "user.unfollow"}}
</button>
{{else}}
<button hx-post="{{.ContextUser.HomeLink}}?action=follow" class="ui basic primary button">
{{svg "octicon-person"}} {{ctx.Locale.Tr "user.follow"}}
</button>
{{end}}
</li>
<li class="block" hx-target="#profile-avatar-card" hx-indicator="#profile-avatar-card">
{{if $.IsBlocked}}
<button class="ui basic red button" hx-post="{{.ContextUser.HomeLink}}?action=unblock">
{{svg "octicon-person"}} {{ctx.Locale.Tr "user.unblock"}}
</button>
{{else}}
<button type="submit" class="ui basic orange button" data-modal-id="block-user" hx-post="{{.ContextUser.HomeLink}}?action=block" hx-confirm="-">
{{svg "octicon-blocked"}} {{ctx.Locale.Tr "user.block"}}
</button>
{{end}}
</li>
{{if .IsModerationEnabled}}
<li class="report">
<a class="ui basic orange button" href="{{AppSubUrl}}/report_abuse?type=user&id={{.ContextUser.ID}}">{{ctx.Locale.Tr "moderation.report_abuse"}}</a>
</li>
{{end}}
{{end}}
</ul> </ul>
</div> </div>
</div> </div>

View file

@ -364,7 +364,7 @@ the click will succeed,
but the depending interaction won't, but the depending interaction won't,
although playwright repeatedly tries to find the content. 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. This code retries the dropdown click until the second item is found.
~~~js ~~~js

View file

@ -12,12 +12,13 @@ test.use({user: 'user2'});
test('Dimmed modal', async ({page}) => { test('Dimmed modal', async ({page}) => {
await page.goto('/user1'); await page.goto('/user1');
await expect(page.locator('.block')).toContainText('Block'); await expect(page.locator('#action-block')).toContainText('Block');
// Ensure the modal is hidden // Ensure the modal is hidden
await expect(page.locator('#block-user')).toBeHidden(); 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. // Modal and dimmer should be visible.
await expect(page.locator('#block-user')).toBeVisible(); await expect(page.locator('#block-user')).toBeVisible();
@ -31,7 +32,8 @@ test('Dimmed modal', async ({page}) => {
await save_visual(page); await save_visual(page);
// Open the block modal and make the dimmer visible again. // 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('#block-user')).toBeVisible();
await expect(page.locator('.ui.dimmer')).toBeVisible(); await expect(page.locator('.ui.dimmer')).toBeVisible();
await expect(page.locator('.ui.dimmer')).toHaveCount(1); await expect(page.locator('.ui.dimmer')).toHaveCount(1);

View file

@ -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();
});

View file

@ -2,6 +2,7 @@
// routers/web/user/** // routers/web/user/**
// templates/shared/user/** // templates/shared/user/**
// web_src/js/features/common-global.js // web_src/js/features/common-global.js
// web_src/js/modules/dropdown.ts
// @watch end // @watch end
import {expect} from '@playwright/test'; import {expect} from '@playwright/test';
@ -9,13 +10,11 @@ import {save_visual, test} from './utils_e2e.ts';
test.use({user: 'user2'}); test.use({user: 'user2'});
test('Follow actions', async ({page}) => { test('Follow and block actions', async ({page}) => {
await page.goto('/user1'); await page.goto('/user1');
// Check if following and then unfollowing works. // Check if following and then unfollowing works.
// This checks that the event listeners of const followButton = page.locator('.primary-action button');
// the buttons aren't disappearing.
const followButton = page.locator('.follow');
await expect(followButton).toContainText('Follow'); await expect(followButton).toContainText('Follow');
await followButton.click(); await followButton.click();
await expect(followButton).toContainText('Unfollow'); await expect(followButton).toContainText('Unfollow');
@ -23,13 +22,19 @@ test('Follow actions', async ({page}) => {
await expect(followButton).toContainText('Follow'); await expect(followButton).toContainText('Follow');
// Simple block interaction. // 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 expect(page.locator('#block-user')).toBeVisible();
await save_visual(page); await save_visual(page);
await page.locator('#block-user .ok').click(); 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(); await expect(page.locator('#block-user')).toBeHidden();
// Check that following the user yields in a error being shown. // Check that following the user yields in a error being shown.
@ -40,6 +45,7 @@ test('Follow actions', async ({page}) => {
await save_visual(page); await save_visual(page);
// Unblock interaction. // Unblock interaction.
await page.locator('.block').click(); await actionsDropdownBtn.click();
await expect(page.locator('.block')).toContainText('Block'); await blockButton.click();
await expect(blockButton).toContainText('Block');
}); });

View file

@ -16,15 +16,15 @@ import (
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
) )
// TestUserProfileActivity ensures visibility and correctness of elements related to activity of a user: // TestUserProfileAttributes ensures visibility and correctness of elements related to activity of a user:
// - RSS feed button (doesn't test `other.ENABLE_FEED:false`) // - RSS/atom feed links (doesn't test `other.ENABLE_FEED:false`) and a few other links nearby
// - Public activity tab // - Public activity tab
// - Banner/hint in the tab // - Banner/hint in the tab
// - "Configure" link in the hint // - "Configure" link in the hint
// These elements might depend on the following: // These elements might depend on the following:
// - Profile visibility // - Profile visibility
// - Public activity visibility // - Public activity visibility
func TestUserProfileActivity(t *testing.T) { func TestUserProfileAttributes(t *testing.T) {
defer test.MockVariableValue(&setting.AppSubURL, "/sub")() defer test.MockVariableValue(&setting.AppSubURL, "/sub")()
defer tests.PrepareTestEnv(t)() defer tests.PrepareTestEnv(t)()
// This test needs multiple users with different access statuses to check for all possible states // 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. // Set activity visibility of user2 to public. This is the default, but won't hurt to set it before testing.
testChangeUserActivityVisibility(t, userRegular, "off") testChangeUserActivityVisibility(t, userRegular, "off")
// Verify availability of RSS button and activity tab // Verify availability of activity tab and other links
testUser2ActivityButtonsAvailability(t, userAdmin, true) testUser2ActivityLinksAvailability(t, userAdmin, true, true, false)
testUser2ActivityButtonsAvailability(t, userRegular, true) testUser2ActivityLinksAvailability(t, userRegular, true, false, true)
testUser2ActivityButtonsAvailability(t, userGuest, true) testUser2ActivityLinksAvailability(t, userGuest, true, false, false)
// Verify the hint for all types of users: admin, self, guest // 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) 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 // Set profile visibility of user2 back to public
testChangeUserProfileVisibility(t, userRegular, structs.VisibleTypePublic) testChangeUserProfileVisibility(t, userRegular, structs.VisibleTypePublic)
// = Private acitivty = // = Private activity =
// Set activity visibility of user2 to private // Set activity visibility of user2 to private
testChangeUserActivityVisibility(t, userRegular, "on") testChangeUserActivityVisibility(t, userRegular, "on")
// Verify availability of RSS button and activity tab // Verify availability of activity tab and other links
testUser2ActivityButtonsAvailability(t, userAdmin, true) testUser2ActivityLinksAvailability(t, userAdmin, true, true, false)
testUser2ActivityButtonsAvailability(t, userRegular, true) testUser2ActivityLinksAvailability(t, userRegular, true, false, true)
testUser2ActivityButtonsAvailability(t, userGuest, false) testUser2ActivityLinksAvailability(t, userGuest, false, false, false)
// Verify the hint for all types of users: admin, self, guest // 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) 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") hintLink, hintLinkExists := page.Find("#visibility-hint a").Attr("href")
// Check that the hint aligns with the actual feed availability // Check that the hint aligns with the actual feed availability
assert.Equal(t, availability, page.Find("#activity-feed").Length() > 0) page.AssertElement(t, "#activity-feed", availability)
// Check availability of RSS feed button too
assert.Equal(t, availability, page.Find("#profile-avatar-card a[href='/sub/user2.rss']").Length() > 0)
// Check that the current tab is displayed and is active regardless of it's actual availability // Check that the current tab is displayed and is active regardless of it's actual availability
// For example, on /<user> it wouldn't be available to guest, but it should be still present on /<user>?tab=activity // For example, on /<user> it wouldn't be available to guest, but it should be still present on /<user>?tab=activity
@ -126,10 +123,21 @@ func testUser2ActivityVisibility(t *testing.T, session *TestSession, hint string
return "" return ""
} }
// testUser2ActivityButtonsAvailability checks visibility of Public activity tab on main profile page // testUser2ActivityLinksAvailability checks visibility of:
func testUser2ActivityButtonsAvailability(t *testing.T, session *TestSession, buttons bool) { // * 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() t.Helper()
response := session.MakeRequest(t, NewRequest(t, "GET", "/user2"), http.StatusOK) response := session.MakeRequest(t, NewRequest(t, "GET", "/user2"), http.StatusOK)
page := NewHTMLParser(t, response.Body) 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)
} }

View file

@ -19,6 +19,7 @@
@import "./modules/dimmer.css"; @import "./modules/dimmer.css";
@import "./modules/switch.css"; @import "./modules/switch.css";
@import "./modules/dropdown.css";
@import "./modules/select.css"; @import "./modules/select.css";
@import "./modules/tippy.css"; @import "./modules/tippy.css";
@import "./modules/breadcrumb.css"; @import "./modules/breadcrumb.css";

View file

@ -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;
}

View file

@ -21,25 +21,26 @@
} }
.user.profile .ui.card .extra.content > ul > li { .user.profile .ui.card .extra.content > ul > li {
padding: 10px;
display: flex; display: flex;
padding: 0.75rem;
gap: 0.5rem;
list-style: none; list-style: none;
align-items: center; align-items: center;
gap: 0.25em;
} }
.user.profile .ui.card .extra.content > ul > li:not(:last-child) { .user.profile .ui.card .extra.content > ul > li:not(:last-child) {
border-bottom: 1px solid var(--color-secondary); border-bottom: 1px solid var(--color-secondary);
} }
.user.profile .ui.card .extra.content > ul > li .svg { .user.profile .ui.card .actions {
margin-left: 1px; padding: 0.75rem;
margin-right: 5px; 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 .primary-action .ui.button {
.user.profile .ui.card .extra.content > ul > li.block .ui.button,
.user.profile .ui.card .extra.content > ul > li.report .ui.button {
align-items: center; align-items: center;
display: flex; display: flex;
justify-content: center; justify-content: center;

View file

@ -75,6 +75,7 @@ import {initCopyContent} from './features/copycontent.js';
import {initCaptcha} from './features/captcha.js'; import {initCaptcha} from './features/captcha.js';
import {initRepositoryActionView} from './components/RepoActionView.vue'; import {initRepositoryActionView} from './components/RepoActionView.vue';
import {initGlobalTooltips} from './modules/tippy.js'; import {initGlobalTooltips} from './modules/tippy.js';
import {initDropdowns} from './modules/dropdown.ts';
import {initGiteaFomantic} from './modules/fomantic.js'; import {initGiteaFomantic} from './modules/fomantic.js';
import {onDomReady} from './utils/dom.js'; import {onDomReady} from './utils/dom.js';
import {initRepoIssueList} from './features/repo-issue-list.js'; import {initRepoIssueList} from './features/repo-issue-list.js';
@ -103,6 +104,7 @@ onDomReady(() => {
initGlobalEnterQuickSubmit(); initGlobalEnterQuickSubmit();
initGlobalFormDirtyLeaveConfirm(); initGlobalFormDirtyLeaveConfirm();
initGlobalLinkActions(); initGlobalLinkActions();
initDropdowns();
initCommonOrganization(); initCommonOrganization();
initCommonIssueListQuickGoto(); initCommonIssueListQuickGoto();
@ -191,4 +193,7 @@ onDomReady(() => {
initGltfViewer(); initGltfViewer();
initScopedAccessTokenCategories(); initScopedAccessTokenCategories();
initColorPickers(); initColorPickers();
// Deactivate CSS-only noJS usability supplements
document.body.classList.remove('no-js');
}); });

View file

@ -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<HTMLDetailsElement>('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<HTMLDetailsElement>('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');
});
}