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*51dd2293caInspired-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">
 | 
			
		||||
			<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 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>
 | 
			
		||||
			</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}}
 | 
			
		||||
| 
						 | 
				
			
			@ -42,17 +59,17 @@
 | 
			
		|||
				</li>
 | 
			
		||||
			{{end}}
 | 
			
		||||
			{{if .ShowUserEmail}}
 | 
			
		||||
					<li>
 | 
			
		||||
						{{svg "octicon-mail"}}
 | 
			
		||||
						<a class="tw-flex-1" href="mailto:{{.ContextUser.Email}}" rel="nofollow">{{.ContextUser.Email}}</a>
 | 
			
		||||
						{{if (eq .SignedUserID .ContextUser.ID)}}
 | 
			
		||||
							<a href="{{AppSubUrl}}/user/settings#privacy-user-settings">
 | 
			
		||||
								<i data-tooltip-content="{{ctx.Locale.Tr "user.email_visibility.limited"}}">
 | 
			
		||||
									{{svg "octicon-unlock"}}
 | 
			
		||||
								</i>
 | 
			
		||||
							</a>
 | 
			
		||||
						{{end}}
 | 
			
		||||
					</li>
 | 
			
		||||
				<li>
 | 
			
		||||
					{{svg "octicon-mail"}}
 | 
			
		||||
					<a class="tw-flex-1" href="mailto:{{.ContextUser.Email}}" rel="nofollow">{{.ContextUser.Email}}</a>
 | 
			
		||||
					{{if (eq .SignedUserID .ContextUser.ID)}}
 | 
			
		||||
						<a href="{{AppSubUrl}}/user/settings#privacy-user-settings">
 | 
			
		||||
							<i data-tooltip-content="{{ctx.Locale.Tr "user.email_visibility.limited"}}">
 | 
			
		||||
								{{svg "octicon-unlock"}}
 | 
			
		||||
							</i>
 | 
			
		||||
						</a>
 | 
			
		||||
					{{end}}
 | 
			
		||||
				</li>
 | 
			
		||||
			{{end}}
 | 
			
		||||
			{{if .ContextUser.Website}}
 | 
			
		||||
				<li>
 | 
			
		||||
| 
						 | 
				
			
			@ -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