mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2025-07-07 09:55:41 +02:00
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:
parent
f7d7d67238
commit
7086e7a9ac
15 changed files with 417 additions and 91 deletions
|
@ -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
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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">
|
||||
|
|
47
templates/shared/user/actions_menu.tmpl
Normal file
47
templates/shared/user/actions_menu.tmpl
Normal 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>
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
|
|
106
tests/e2e/dropdown.test.e2e.ts
Normal file
106
tests/e2e/dropdown.test.e2e.ts
Normal 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();
|
||||
});
|
|
@ -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');
|
||||
});
|
||||
|
|
|
@ -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)
|
||||
}
|
|
@ -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";
|
||||
|
|
118
web_src/css/modules/dropdown.css
Normal file
118
web_src/css/modules/dropdown.css
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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');
|
||||
});
|
||||
|
|
35
web_src/js/modules/dropdown.ts
Normal file
35
web_src/js/modules/dropdown.ts
Normal 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');
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue