From 7086e7a9aca397b878edc57fb016853c14655f31 Mon Sep 17 00:00:00 2001 From: 0ko <0ko@noreply.codeberg.org> Date: Tue, 24 Jun 2025 14:16:51 +0200 Subject: [PATCH] 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: * https://github.com/picocss/pico/commit/72a3adb16b2f3248b456da823502f166510720a0 * https://github.com/picocss/pico/commit/51dd2293cab8d77cf13fc2c32b6053ffa654bc56 Inspired-by: KiranMantha Inspired-by: Lucas Larroche Co-authored-by: Beowulf Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/7906 Reviewed-by: Otto Reviewed-by: Beowulf Co-authored-by: 0ko <0ko@noreply.codeberg.org> Co-committed-by: 0ko <0ko@noreply.codeberg.org> --- CODEOWNERS | 1 + options/locale_next/locale_en-US.json | 5 + templates/base/head.tmpl | 2 +- templates/shared/user/actions_menu.tmpl | 47 +++++++ templates/shared/user/profile_big_avatar.tmpl | 91 ++++++-------- tests/e2e/README.md | 2 +- tests/e2e/dimmer.test.e2e.ts | 8 +- tests/e2e/dropdown.test.e2e.ts | 106 ++++++++++++++++ tests/e2e/profile_actions.test.e2e.ts | 24 ++-- ...est.go => user_profile_attributes_test.go} | 46 ++++--- web_src/css/index.css | 1 + web_src/css/modules/dropdown.css | 118 ++++++++++++++++++ web_src/css/user.css | 17 +-- web_src/js/index.js | 5 + web_src/js/modules/dropdown.ts | 35 ++++++ 15 files changed, 417 insertions(+), 91 deletions(-) create mode 100644 templates/shared/user/actions_menu.tmpl create mode 100644 tests/e2e/dropdown.test.e2e.ts rename tests/integration/{user_profile_activity_test.go => user_profile_attributes_test.go} (76%) create mode 100644 web_src/css/modules/dropdown.css create mode 100644 web_src/js/modules/dropdown.ts diff --git a/CODEOWNERS b/CODEOWNERS index 03b0d8753d..34cdceca09 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/options/locale_next/locale_en-US.json b/options/locale_next/locale_en-US.json index 368152095e..b1c98e4551 100644 --- a/options/locale_next/locale_en-US.json +++ b/options/locale_next/locale_en-US.json @@ -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", diff --git a/templates/base/head.tmpl b/templates/base/head.tmpl index 7ec2ac87b3..6357981549 100644 --- a/templates/base/head.tmpl +++ b/templates/base/head.tmpl @@ -25,7 +25,7 @@ {{template "base/head_style" .}} {{template "custom/header" .}} - + {{template "custom/body_outer_pre" .}}
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}}{{.ContextUser.FullName}}{{end}} - {{.ContextUser.Name}} {{if .ContextUser.GetPronouns .IsSigned}} · {{.ContextUser.GetPronouns .IsSigned}}{{end}} {{if .IsAdmin}} - - {{svg "octicon-gear" 18}} - - {{end}} -
- {{svg "octicon-people" 18 "tw-mr-1"}}{{ctx.Locale.TrN .NumFollowers "user.followers_one" "user.followers_few" .NumFollowers}} · {{ctx.Locale.TrN .NumFollowing "user.following_one" "user.following_few" .NumFollowing}} - {{if and .EnableFeed (or .IsAdmin (eq .SignedUserID .ContextUser.ID) (not .ContextUser.KeepActivityPrivate))}} - {{svg "octicon-rss" 18}} + {{.ContextUser.Name}} {{if .ContextUser.GetPronouns .IsSigned}} · {{.ContextUser.GetPronouns .IsSigned}}{{end}} +
+ {{if $showFollow}} +
+
+ {{if .IsFollowing}} + + {{else}} + + {{end}} +
+ {{template "shared/user/actions_menu" .}} +
+ {{end}}
    {{if .ContextUser.Location}} @@ -42,17 +59,17 @@ {{end}} {{if .ShowUserEmail}} -
  • - {{svg "octicon-mail"}} - {{.ContextUser.Email}} - {{if (eq .SignedUserID .ContextUser.ID)}} - - - {{svg "octicon-unlock"}} - - - {{end}} -
  • +
  • + {{svg "octicon-mail"}} + {{.ContextUser.Email}} + {{if (eq .SignedUserID .ContextUser.ID)}} + + + {{svg "octicon-unlock"}} + + + {{end}} +
  • {{end}} {{if .ContextUser.Website}}
  • @@ -73,7 +90,10 @@
  • {{end}} {{end}} -
  • {{svg "octicon-calendar"}} {{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .ContextUser.CreatedUnix)}}
  • +
  • + {{svg "octicon-calendar"}} + {{ctx.Locale.Tr "user.joined_on" (DateUtils.AbsoluteShort .ContextUser.CreatedUnix)}} +
  • {{if and .Orgs .HasOrgsVisible}}
    • @@ -100,35 +120,6 @@
  • {{end}} - {{if and .IsSigned (ne .SignedUserID .ContextUser.ID)}} - -
  • - {{if $.IsBlocked}} - - {{else}} - - {{end}} -
  • - {{if .IsModerationEnabled}} -
  • - {{ctx.Locale.Tr "moderation.report_abuse"}} -
  • - {{end}} - {{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'); + }); +}