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.
web_src/.* @beowulf @gusted
web_src/css/.* @0ko
# HTML templates used by the backend.
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.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",
"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",
"moderation.report_abuse": "Report abuse",
"moderation.report_content": "Report content",

View file

@ -25,7 +25,7 @@
{{template "base/head_style" .}}
{{template "custom/header" .}}
</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" .}}
<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}}
{{template "base/alert" .}}
{{end}}
{{$showFollow := and .IsSigned (ne .SignedUserID .ContextUser.ID)}}
<div id="profile-avatar-card" class="ui card" hx-swap="morph">
<div id="profile-avatar" class="content tw-flex">
{{if eq .SignedUserID .ContextUser.ID}}
@ -16,18 +19,32 @@
</div>
<div class="content tw-break-anywhere profile-avatar-name">
{{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}}
<a class="muted" href="{{AppSubUrl}}/admin/users/{{.ContextUser.ID}}" data-tooltip-content="{{ctx.Locale.Tr "admin.users.details"}}">
{{svg "octicon-gear" 18}}
</a>
{{end}}</span>
<div class="tw-mt-2">
<span class="username">{{.ContextUser.Name}} {{if .ContextUser.GetPronouns .IsSigned}} · {{.ContextUser.GetPronouns .IsSigned}}{{end}}</span>
<div class="tw-mt-2 tw-flex tw-items-center tw-gap-2 tw-justify-center">
<span>
<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>
{{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>
</span>
{{if not $showFollow}}
{{template "shared/user/actions_menu" .}}
{{end}}
</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">
<ul>
{{if .ContextUser.Location}}
@ -73,7 +90,10 @@
</li>
{{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}}
<li>
<ul class="user-orgs">
@ -100,35 +120,6 @@
</ul>
</li>
{{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>
</div>
</div>

View file

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

View file

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

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/**
// 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');
});

View file

@ -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 /<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 ""
}
// 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)
}

View file

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

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 {
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;

View file

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

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