mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-02-19 02:29:20 -05:00
feat(ui): JS-less dropdowns in navbar (#10025)
Replaced dropdowns in the navbar with JS-less ones from https://codeberg.org/forgejo/forgejo/pulls/7906. Also made some changes to the dropdown component: * fixed variable name * painted backgrounds (hover, focus) are now consistently applied to the actual interactive items (`<a>`, `<button>`), not to `<li>`. This is consistent with how backgrounds are conditionally applied to pre-selected (`.active`) items and is better, as it allows to place additional things to `<li>`... * ...`<hr>` can now be placed in some `<li>` instead of requiring splitting into multiple `<ul>`. This is simpler in code and I am guessing this should be better for a11y as screen readers can cast one continuous list instead of multiple ones. But have no hard proof that this is actually better. My main motivation was to avoid ugly mistake-prone tmpl logic where unconditional `<ul>` was getting closed and reopened inside of a condition. I should note that on mobile all items, including these dropdowns, are hidden in another dropdown, and it stays JS-dependand for now. So this PR only makes this part of the UI JS-less for desktop. Reviewed-on: https://codeberg.org/forgejo/forgejo/pulls/10025 Reviewed-by: Robert Wolff <mahlzahn@posteo.de> Reviewed-by: Gusted <gusted@noreply.codeberg.org> Co-authored-by: 0ko <0ko@noreply.codeberg.org> Co-committed-by: 0ko <0ko@noreply.codeberg.org>
This commit is contained in:
parent
67df538958
commit
f0b4e3b943
9 changed files with 348 additions and 170 deletions
|
|
@ -55,24 +55,27 @@
|
|||
<!-- the full dropdown menus -->
|
||||
<div class="navbar-right ui secondary menu">
|
||||
{{if and .IsSigned .HideNavbarLinks}}
|
||||
<div class="ui dropdown jump item" data-tooltip-content="{{ctx.Locale.Tr "user_profile_and_more"}}">
|
||||
<span class="text tw-flex tw-items-center">
|
||||
{{ctx.AvatarUtils.Avatar .SignedUser 24 "tw-mr-1"}}
|
||||
<details class="dropdown dir-rtl">
|
||||
<summary data-tooltip-content="{{ctx.Locale.Tr "user_profile_and_more"}}">
|
||||
{{ctx.AvatarUtils.Avatar .SignedUser 24}}
|
||||
<span class="only-mobile tw-ml-2">{{.SignedUser.Name}}</span>
|
||||
<span class="not-mobile">{{svg "octicon-triangle-down"}}</span>
|
||||
</span>
|
||||
<div class="menu user-menu">
|
||||
<div class="ui header">
|
||||
{{ctx.Locale.Tr "signed_in_as"}} <strong>{{.SignedUser.Name}}</strong>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<a class="item link-action" href data-url="{{AppSubUrl}}/user/logout">
|
||||
{{svg "octicon-sign-out"}}
|
||||
{{ctx.Locale.Tr "sign_out"}}
|
||||
</a>
|
||||
</div><!-- end content avatar menu -->
|
||||
</div><!-- end dropdown avatar menu -->
|
||||
</summary>
|
||||
<div class="content">
|
||||
<span class="header">
|
||||
<span>{{ctx.Locale.Tr "signed_in_as"}} <strong>{{.SignedUser.Name}}</strong></span>
|
||||
</span>
|
||||
<hr>
|
||||
<ul>
|
||||
<li>
|
||||
<a class="link-action" href data-url="{{AppSubUrl}}/user/logout">
|
||||
{{svg "octicon-sign-out"}}
|
||||
{{ctx.Locale.Tr "sign_out"}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
{{else if .IsSigned}}
|
||||
{{if EnableTimetracking}}
|
||||
<a class="active-stopwatch-trigger item tw-mx-0{{if not .ActiveStopwatch}} tw-hidden{{end}}" href="{{.ActiveStopwatch.IssueLink}}" title="{{ctx.Locale.Tr "active_stopwatch"}}">
|
||||
|
|
@ -116,79 +119,100 @@
|
|||
</div>
|
||||
</a>
|
||||
|
||||
<div class="ui dropdown jump item tw-mx-0 tw-pr-2" data-tooltip-content="{{ctx.Locale.Tr "create_new"}}">
|
||||
<span class="text">
|
||||
<details class="dropdown dir-rtl">
|
||||
<summary data-tooltip-content="{{ctx.Locale.Tr "create_new"}}">
|
||||
{{svg "octicon-plus"}}
|
||||
<span class="not-mobile">{{svg "octicon-triangle-down"}}</span>
|
||||
<span class="only-mobile">{{ctx.Locale.Tr "create_new"}}</span>
|
||||
</span>
|
||||
<div class="menu">
|
||||
<a class="item" href="{{AppSubUrl}}/repo/create">
|
||||
{{svg "octicon-plus"}} {{ctx.Locale.Tr "new_repo.link"}}
|
||||
</a>
|
||||
{{if not .DisableMigrations}}
|
||||
<a class="item" href="{{AppSubUrl}}/repo/migrate">
|
||||
{{svg "octicon-repo-push"}} {{ctx.Locale.Tr "new_migrate.link"}}
|
||||
</a>
|
||||
{{end}}
|
||||
{{if .SignedUser.CanCreateOrganization}}
|
||||
<a class="item" href="{{AppSubUrl}}/org/create">
|
||||
{{svg "octicon-organization"}} {{ctx.Locale.Tr "new_org.link"}}
|
||||
</a>
|
||||
{{end}}
|
||||
</div><!-- end content create new menu -->
|
||||
</div><!-- end dropdown menu create new -->
|
||||
</summary>
|
||||
<div class="content">
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{AppSubUrl}}/repo/create">
|
||||
{{svg "octicon-plus"}} {{ctx.Locale.Tr "new_repo.link"}}
|
||||
</a>
|
||||
</li>
|
||||
{{if not .DisableMigrations}}
|
||||
<li>
|
||||
<a href="{{AppSubUrl}}/repo/migrate">
|
||||
{{svg "octicon-repo-push"}} {{ctx.Locale.Tr "new_migrate.link"}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
{{if .SignedUser.CanCreateOrganization}}
|
||||
<li>
|
||||
<a href="{{AppSubUrl}}/org/create">
|
||||
{{svg "octicon-organization"}} {{ctx.Locale.Tr "new_org.link"}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="ui dropdown jump item tw-mx-0 tw-pr-2" data-tooltip-content="{{ctx.Locale.Tr "user_profile_and_more"}}">
|
||||
<span class="text tw-flex tw-items-center">
|
||||
{{ctx.AvatarUtils.Avatar .SignedUser 24 "tw-mr-1"}}
|
||||
<details class="dropdown dir-rtl">
|
||||
<summary data-tooltip-content="{{ctx.Locale.Tr "user_profile_and_more"}}">
|
||||
{{ctx.AvatarUtils.Avatar .SignedUser 24}}
|
||||
<span class="only-mobile tw-ml-2">{{.SignedUser.Name}}</span>
|
||||
<span class="not-mobile">{{svg "octicon-triangle-down"}}</span>
|
||||
</span>
|
||||
<div class="menu user-menu">
|
||||
<div class="ui header">
|
||||
{{ctx.Locale.Tr "signed_in_as"}} <strong>{{.SignedUser.Name}}</strong>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
<a class="item" href="{{.SignedUser.HomeLink}}">
|
||||
{{svg "octicon-person"}}
|
||||
{{ctx.Locale.Tr "your_profile"}}
|
||||
</a>
|
||||
{{if not .DisableStars}}
|
||||
<a class="item" href="{{.SignedUser.HomeLink}}?tab=stars">
|
||||
{{svg "octicon-star"}}
|
||||
{{ctx.Locale.Tr "your_starred"}}
|
||||
</a>
|
||||
{{end}}
|
||||
<a class="item" href="{{AppSubUrl}}/notifications/subscriptions">
|
||||
{{svg "octicon-bell"}}
|
||||
{{ctx.Locale.Tr "notification.subscriptions"}}
|
||||
</a>
|
||||
<a class="{{if .PageIsUserSettings}}active {{end}}item" href="{{AppSubUrl}}/user/settings">
|
||||
{{svg "octicon-tools"}}
|
||||
{{ctx.Locale.Tr "your_settings"}}
|
||||
</a>
|
||||
<a class="item" target="_blank" rel="noopener noreferrer" href="https://forgejo.org/docs/latest/">
|
||||
{{svg "octicon-question"}}
|
||||
{{ctx.Locale.Tr "help"}}
|
||||
</a>
|
||||
{{if .IsAdmin}}
|
||||
<div class="divider"></div>
|
||||
|
||||
<a class="{{if .PageIsAdmin}}active {{end}}item" href="{{AppSubUrl}}/admin">
|
||||
{{svg "octicon-server"}}
|
||||
{{ctx.Locale.Tr "admin_panel"}}
|
||||
</a>
|
||||
{{end}}
|
||||
|
||||
<div class="divider"></div>
|
||||
<a class="item link-action" href data-url="{{AppSubUrl}}/user/logout">
|
||||
{{svg "octicon-sign-out"}}
|
||||
{{ctx.Locale.Tr "sign_out"}}
|
||||
</a>
|
||||
</div><!-- end content avatar menu -->
|
||||
</div><!-- end dropdown avatar menu -->
|
||||
</summary>
|
||||
<div class="content">
|
||||
<span class="header">
|
||||
<span>{{ctx.Locale.Tr "signed_in_as"}} <strong>{{.SignedUser.Name}}</strong></span>
|
||||
</span>
|
||||
<hr>
|
||||
<ul>
|
||||
<li>
|
||||
<a href="{{.SignedUser.HomeLink}}">
|
||||
{{svg "octicon-person"}}
|
||||
{{ctx.Locale.Tr "your_profile"}}
|
||||
</a>
|
||||
</li>
|
||||
{{if not .DisableStars}}
|
||||
<li>
|
||||
<a href="{{.SignedUser.HomeLink}}?tab=stars">
|
||||
{{svg "octicon-star"}}
|
||||
{{ctx.Locale.Tr "your_starred"}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
<li>
|
||||
<a href="{{AppSubUrl}}/notifications/subscriptions">
|
||||
{{svg "octicon-bell"}}
|
||||
{{ctx.Locale.Tr "notification.subscriptions"}}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a {{if .PageIsUserSettings}}class="active"{{end}} href="{{AppSubUrl}}/user/settings">
|
||||
{{svg "octicon-tools"}}
|
||||
{{ctx.Locale.Tr "your_settings"}}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a target="_blank" rel="noopener noreferrer" href="https://forgejo.org/docs/latest/">
|
||||
{{svg "octicon-question"}}
|
||||
{{ctx.Locale.Tr "help"}}
|
||||
</a>
|
||||
</li>
|
||||
{{if .IsAdmin}}
|
||||
<hr>
|
||||
<li>
|
||||
<a {{if .PageIsAdmin}}class="active"{{end}} href="{{AppSubUrl}}/admin">
|
||||
{{svg "octicon-server"}}
|
||||
{{ctx.Locale.Tr "admin_panel"}}
|
||||
</a>
|
||||
</li>
|
||||
{{end}}
|
||||
<li>
|
||||
<hr>
|
||||
<a class="link-action" href data-url="{{AppSubUrl}}/user/logout">
|
||||
{{svg "octicon-sign-out"}}
|
||||
{{ctx.Locale.Tr "sign_out"}}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</details>
|
||||
{{else}}
|
||||
{{if .ShowRegistrationButton}}
|
||||
<a class="item{{if .PageIsSignUp}} active{{end}}" href="{{AppSubUrl}}/user/sign_up">
|
||||
|
|
|
|||
|
|
@ -19,9 +19,10 @@ test('JS enhanced interaction', async ({page}) => {
|
|||
await expect(nojsNotice).toBeHidden();
|
||||
|
||||
// Open and close by clicking summary
|
||||
const dropdown = page.locator('details.dropdown');
|
||||
const dropdownSummary = page.locator('details.dropdown > summary');
|
||||
const dropdownContent = page.locator('details.dropdown > .content');
|
||||
const selectorPrefix = '#profile-avatar-card details.dropdown';
|
||||
const dropdown = page.locator(selectorPrefix);
|
||||
const dropdownSummary = page.locator(`${selectorPrefix} > summary`);
|
||||
const dropdownContent = page.locator(`${selectorPrefix} > .content`);
|
||||
await expect(dropdownContent).toBeHidden();
|
||||
await dropdownSummary.click();
|
||||
await expect(dropdownContent).toBeVisible();
|
||||
|
|
@ -116,8 +117,9 @@ test('No JS interaction', async ({browser}) => {
|
|||
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 > .content');
|
||||
const selectorPrefix = '#profile-avatar-card details.dropdown';
|
||||
const dropdownSummary = nojsPage.locator(`${selectorPrefix} > summary`);
|
||||
const dropdownContent = nojsPage.locator(`${selectorPrefix} > .content`);
|
||||
await expect(dropdownContent).toBeHidden();
|
||||
await dropdownSummary.click();
|
||||
await expect(dropdownContent).toBeVisible();
|
||||
|
|
@ -151,23 +153,7 @@ test('No JS interaction', async ({browser}) => {
|
|||
await expect(dropdownContent).toBeVisible();
|
||||
});
|
||||
|
||||
test('Visual properties', async ({browser, isMobile}) => {
|
||||
const context = await browser.newContext({javaScriptEnabled: false});
|
||||
const page = await context.newPage();
|
||||
|
||||
// User profile has dropdown used as an ellipsis menu
|
||||
await page.goto('/user1');
|
||||
|
||||
// Has `.border` and pretty small default `inline-padding:`
|
||||
const summary = page.locator('details.dropdown > summary');
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).border)).toBe('1px solid rgba(0, 0, 0, 0.114)');
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).paddingInline)).toBe('7px');
|
||||
|
||||
// Background
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
|
||||
await summary.click();
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(226, 226, 229)');
|
||||
|
||||
test.describe(`Visual properties`, () => {
|
||||
async function evaluateDropdownItems(page, selector, direction, height) {
|
||||
const computedStyles = await page.locator(selector).evaluateAll((items) =>
|
||||
items.map((item) => {
|
||||
|
|
@ -184,36 +170,60 @@ test('Visual properties', async ({browser, isMobile}) => {
|
|||
}
|
||||
}
|
||||
|
||||
// Direction and item height
|
||||
const content = page.locator('details.dropdown > .content');
|
||||
const itemsSel = 'details.dropdown > .content > ul > li';
|
||||
if (isMobile) {
|
||||
test('User profile', async ({browser, isMobile}) => {
|
||||
const context = await browser.newContext({javaScriptEnabled: false});
|
||||
const page = await context.newPage();
|
||||
|
||||
// User profile has dropdown used as an ellipsis menu
|
||||
await page.goto('/user1');
|
||||
const selectorPrefix = '#profile-avatar-card details.dropdown';
|
||||
const summary = page.locator(`${selectorPrefix} > summary`);
|
||||
|
||||
// Has `.border` and pretty small default `inline-padding:`
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).border)).toBe('1px solid rgba(0, 0, 0, 0.114)');
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).paddingInline)).toBe('7px');
|
||||
|
||||
// Background
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
|
||||
await summary.click();
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(226, 226, 229)');
|
||||
|
||||
// Direction and item height
|
||||
if (isMobile) {
|
||||
// `<ul>`'s direction is reversed
|
||||
expect(await page.locator(`${selectorPrefix} > .content`).evaluate((el) => getComputedStyle(el).direction)).toBe('rtl');
|
||||
// `@media (pointer: coarse)` makes items taller
|
||||
await evaluateDropdownItems(page, `${selectorPrefix} > .content > ul > li`, 'ltr', '40px');
|
||||
} else {
|
||||
// Both use default direction
|
||||
expect(await page.locator(`${selectorPrefix} > .content`).evaluate((el) => getComputedStyle(el).direction)).toBe('ltr');
|
||||
// Regular item height
|
||||
await evaluateDropdownItems(page, `${selectorPrefix} > .content > ul > li`, 'ltr', '34px');
|
||||
}
|
||||
});
|
||||
|
||||
test('Explore sort', async ({browser, isMobile}) => {
|
||||
const context = await browser.newContext({javaScriptEnabled: false});
|
||||
const page = await context.newPage();
|
||||
|
||||
// `/explore/users` has dropdown used as a sort options menu with text in the opener
|
||||
await page.goto('/explore/users');
|
||||
const selectorPrefix = '.list-header details.dropdown';
|
||||
const summary = page.locator(`${selectorPrefix} > summary`);
|
||||
await summary.click();
|
||||
|
||||
// No `.border` and increased `inline-padding:` from `.options`
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).borderWidth)).toBe('0px');
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).paddingInline)).toBe('10.5px');
|
||||
|
||||
// `<ul>`'s direction is reversed
|
||||
expect(await content.evaluate((el) => getComputedStyle(el).direction)).toBe('rtl');
|
||||
// `@media (pointer: coarse)` makes items taller
|
||||
await evaluateDropdownItems(page, itemsSel, 'ltr', '40px');
|
||||
} else {
|
||||
// Both use default direction
|
||||
expect(await content.evaluate((el) => getComputedStyle(el).direction)).toBe('ltr');
|
||||
// Regular item height
|
||||
await evaluateDropdownItems(page, itemsSel, 'ltr', '34px');
|
||||
}
|
||||
expect(await page.locator(`${selectorPrefix} > .content`).evaluate((el) => getComputedStyle(el).direction)).toBe('rtl');
|
||||
await evaluateDropdownItems(page, `${selectorPrefix} > .content > ul > li`, 'ltr', isMobile ? '40px' : '34px');
|
||||
|
||||
// `/explore/users` has dropdown used as a sort options menu with text in the opener
|
||||
await page.goto('/explore/users');
|
||||
await summary.click();
|
||||
|
||||
// No `.border` and increased `inline-padding:` from `.options`
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).borderWidth)).toBe('0px');
|
||||
expect(await summary.evaluate((el) => getComputedStyle(el).paddingInline)).toBe('10.5px');
|
||||
|
||||
// `<ul>`'s direction is reversed
|
||||
expect(await content.evaluate((el) => getComputedStyle(el).direction)).toBe('rtl');
|
||||
await evaluateDropdownItems(page, itemsSel, 'ltr', isMobile ? '40px' : '34px');
|
||||
|
||||
// Background of inactive and `.active` items
|
||||
const activeItem = page.locator('details.dropdown > .content > ul > li:first-child > a');
|
||||
const inactiveItem = page.locator('details.dropdown > .content > ul > li:last-child > a');
|
||||
expect(await activeItem.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(226, 226, 229)');
|
||||
expect(await inactiveItem.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
|
||||
// Background of inactive and `.active` items
|
||||
const activeItem = page.locator(`${selectorPrefix}> .content > ul > li:first-child > a`);
|
||||
const inactiveItem = page.locator(`${selectorPrefix}> .content > ul > li:last-child > a`);
|
||||
expect(await activeItem.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgb(226, 226, 229)');
|
||||
expect(await inactiveItem.evaluate((el) => getComputedStyle(el).backgroundColor)).toBe('rgba(0, 0, 0, 0)');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,7 +40,7 @@ test('WebAuthn register & login flow', async ({browser, request}, workerInfo) =>
|
|||
await expect(page.getByRole('button', {name: 'Remove'})).toBeVisible(); // "Remove" button is visible, indicating that the security key was added
|
||||
|
||||
// Logout.
|
||||
await page.locator('div[aria-label="Profile and settings…"]').click();
|
||||
await page.locator('summary[aria-label="Profile and settings…"]').click();
|
||||
await page.getByText('Sign out').click();
|
||||
await expect(async () => {
|
||||
await page.waitForURL(`${workerInfo.project.use.baseURL}/`);
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ package integration
|
|||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/models/unittest"
|
||||
|
|
@ -27,12 +26,6 @@ func TestCommonNavigationElements(t *testing.T) {
|
|||
response := session.MakeRequest(t, NewRequest(t, "GET", "/"), http.StatusOK)
|
||||
page := NewHTMLParser(t, response.Body)
|
||||
|
||||
// Navbar
|
||||
links := page.Find("#navbar .dropdown[data-tooltip-content='Create…'] .menu")
|
||||
assert.Equal(t, locale.TrString("new_repo.link"), strings.TrimSpace(links.Find("a[href='/repo/create']").Text()))
|
||||
assert.Equal(t, locale.TrString("new_migrate.link"), strings.TrimSpace(links.Find("a[href='/repo/migrate']").Text()))
|
||||
assert.Equal(t, locale.TrString("new_org.link"), strings.TrimSpace(links.Find("a[href='/org/create']").Text()))
|
||||
|
||||
// After footer: index.js
|
||||
page.AssertElement(t, "script[src^='/assets/js/index.js']", true)
|
||||
onerror, _ := page.Find("script[src^='/assets/js/index.js']").Attr("onerror")
|
||||
|
|
|
|||
111
tests/integration/navbar_test.go
Normal file
111
tests/integration/navbar_test.go
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
||||
// SPDX-License-Identifier: GPL-3.0-or-later
|
||||
|
||||
package integration
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"forgejo.org/modules/setting"
|
||||
"forgejo.org/modules/test"
|
||||
"forgejo.org/modules/translation"
|
||||
"forgejo.org/tests"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
/* TestNavbarItems asserts go tmpl logic of navbar */
|
||||
func TestNavbarItems(t *testing.T) {
|
||||
defer tests.PrepareTestEnv(t)()
|
||||
|
||||
// The navbar can be tested on any page, but preferably a lightweight one
|
||||
testPage := "/explore/organizations"
|
||||
locale := translation.NewLocale("en-US")
|
||||
|
||||
adminUser := loginUser(t, "user1")
|
||||
regularUser := loginUser(t, "user2")
|
||||
|
||||
t.Run(`"Create..." dropdown - migrations disallowed`, func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
defer test.MockVariableValue(&setting.Repository.DisableMigrations, true)()
|
||||
|
||||
page := NewHTMLParser(t, regularUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
|
||||
page.AssertElement(t, `details.dropdown a[href="/repo/migrate"]`, false)
|
||||
})
|
||||
|
||||
t.Run(`"Create..." dropdown - creating orgs disallowed`, func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
defer test.MockVariableValue(&setting.Admin.DisableRegularOrgCreation, true)()
|
||||
|
||||
// The restriction applies to a regular user
|
||||
page := NewHTMLParser(t, regularUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
|
||||
page.AssertElement(t, `details.dropdown a[href="/org/create"]`, false)
|
||||
|
||||
// The restriction does not apply to an admin
|
||||
page = NewHTMLParser(t, adminUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
|
||||
page.AssertElement(t, `details.dropdown a[href="/org/create"]`, true)
|
||||
})
|
||||
|
||||
t.Run(`"Create..." dropdown - default conditions`, func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
// Assert that items are present and their contents
|
||||
assertItems := func(t *testing.T, session *TestSession) {
|
||||
page := NewHTMLParser(t, session.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
|
||||
links := page.Find(`#navbar .dropdown:has(summary[data-tooltip-content="Create…"]) .content`)
|
||||
assert.Equal(t, locale.TrString("new_repo.link"), strings.TrimSpace(links.Find(`a[href="/repo/create"]`).Text()))
|
||||
assert.Equal(t, locale.TrString("new_migrate.link"), strings.TrimSpace(links.Find(`a[href="/repo/migrate"]`).Text()))
|
||||
assert.Equal(t, locale.TrString("new_org.link"), strings.TrimSpace(links.Find(`a[href="/org/create"]`).Text()))
|
||||
}
|
||||
assertItems(t, regularUser)
|
||||
assertItems(t, adminUser)
|
||||
})
|
||||
|
||||
t.Run(`User dropdown - stars are disabled`, func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
defer test.MockVariableValue(&setting.Repository.DisableStars, true)()
|
||||
|
||||
page := NewHTMLParser(t, regularUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
|
||||
page.AssertElement(t, `details.dropdown a[href$="?tab=stars"]`, false)
|
||||
})
|
||||
|
||||
t.Run(`User dropdown - default conditions`, func(t *testing.T) {
|
||||
defer tests.PrintCurrentTest(t)()
|
||||
|
||||
assertions := []struct {
|
||||
selector string
|
||||
exists bool
|
||||
}{
|
||||
{`details.dropdown a[href="/user2"]`, true},
|
||||
{`details.dropdown a[href="/user2?tab=stars"]`, true},
|
||||
{`details.dropdown a[href="/notifications/subscriptions"]`, true},
|
||||
{`details.dropdown a[href="/user/settings"]`, true},
|
||||
{`details.dropdown a[href="/admin"]`, false},
|
||||
{`details.dropdown a[href="https://forgejo.org/docs/latest/"]`, true},
|
||||
{`details.dropdown a[data-url="/user/logout"]`, true},
|
||||
}
|
||||
page := NewHTMLParser(t, regularUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
|
||||
for _, assertion := range assertions {
|
||||
page.AssertElement(t, assertion.selector, assertion.exists)
|
||||
}
|
||||
|
||||
assertions = []struct {
|
||||
selector string
|
||||
exists bool
|
||||
}{
|
||||
{`details.dropdown a[href="/user1"]`, true},
|
||||
{`details.dropdown a[href="/user1?tab=stars"]`, true},
|
||||
{`details.dropdown a[href="/notifications/subscriptions"]`, true},
|
||||
{`details.dropdown a[href="/user/settings"]`, true},
|
||||
{`details.dropdown a[href="/admin"]`, true},
|
||||
{`details.dropdown a[href="https://forgejo.org/docs/latest/"]`, true},
|
||||
{`details.dropdown a[data-url="/user/logout"]`, true},
|
||||
}
|
||||
page = NewHTMLParser(t, adminUser.MakeRequest(t, NewRequest(t, "GET", testPage), http.StatusOK).Body)
|
||||
for _, assertion := range assertions {
|
||||
page.AssertElement(t, assertion.selector, assertion.exists)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -120,12 +120,12 @@ quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequa
|
|||
|
||||
// Both guests and logged in users should see the feed option
|
||||
doc := NewHTMLParser(t, MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
|
||||
doc.AssertElement(t, "details.dropdown a[href='/org3.rss']", true)
|
||||
doc.AssertElement(t, "details.dropdown a[href^='/report_abuse']", false)
|
||||
doc.AssertElement(t, ".org-header details.dropdown a[href='/org3.rss']", true)
|
||||
doc.AssertElement(t, ".org-header details.dropdown a[href^='/report_abuse']", false)
|
||||
|
||||
doc = NewHTMLParser(t, loginUser(t, "user10").MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
|
||||
doc.AssertElement(t, "details.dropdown a[href='/org3.rss']", true)
|
||||
doc.AssertElement(t, "details.dropdown a[href^='/report_abuse']", false)
|
||||
doc.AssertElement(t, ".org-header details.dropdown a[href='/org3.rss']", true)
|
||||
doc.AssertElement(t, ".org-header details.dropdown a[href^='/report_abuse']", false)
|
||||
})
|
||||
|
||||
t.Run("More actions - none", func(t *testing.T) {
|
||||
|
|
@ -135,10 +135,10 @@ quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequa
|
|||
|
||||
// The dropdown won't appear if no entries are available, for both guests and logged in users
|
||||
doc := NewHTMLParser(t, MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
|
||||
doc.AssertElement(t, "details.dropdown", false)
|
||||
doc.AssertElement(t, ".org-header details.dropdown", false)
|
||||
|
||||
doc = NewHTMLParser(t, loginUser(t, "user10").MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
|
||||
doc.AssertElement(t, "details.dropdown", false)
|
||||
doc.AssertElement(t, ".org-header details.dropdown", false)
|
||||
})
|
||||
|
||||
t.Run("More actions - moderation", func(t *testing.T) {
|
||||
|
|
@ -148,15 +148,15 @@ quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequa
|
|||
|
||||
// The report option shouldn't be available to a guest
|
||||
doc := NewHTMLParser(t, MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
|
||||
doc.AssertElement(t, "details.dropdown", false)
|
||||
doc.AssertElement(t, ".org-header details.dropdown", false)
|
||||
|
||||
// But should be available to a logged in user
|
||||
doc = NewHTMLParser(t, loginUser(t, "user10").MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
|
||||
doc.AssertElement(t, "details.dropdown a[href^='/report_abuse']", true)
|
||||
doc.AssertElement(t, ".org-header details.dropdown a[href^='/report_abuse']", true)
|
||||
|
||||
// But the org owner shouldn't see the report option
|
||||
doc = NewHTMLParser(t, loginUser(t, "user1").MakeRequest(t, NewRequest(t, "GET", "/org3"), http.StatusOK).Body)
|
||||
doc.AssertElement(t, "details.dropdown", false)
|
||||
doc.AssertElement(t, ".org-header details.dropdown", false)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -169,7 +169,7 @@ func TestGlobalTwoFactorRequirement(t *testing.T) {
|
|||
resp := session.MakeRequest(t, req, http.StatusNotFound)
|
||||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
assert.Greater(t, htmlDoc.Find(".navbar-left > a.item").Length(), 1) // show the Logo, and other links
|
||||
assert.Greater(t, htmlDoc.Find(".navbar-right .user-menu a.item").Length(), 1)
|
||||
assert.Greater(t, htmlDoc.Find(".navbar-right details.dropdown a").Length(), 1)
|
||||
|
||||
// 500 page
|
||||
reset := enableDevtest()
|
||||
|
|
@ -191,7 +191,7 @@ func TestGlobalTwoFactorRequirement(t *testing.T) {
|
|||
htmlDoc := NewHTMLParser(t, resp.Body)
|
||||
assert.Equal(t, 1, htmlDoc.Find(".navbar-left > a.item").Length()) // only show the Logo, no other links
|
||||
|
||||
userLinks := htmlDoc.Find(".navbar-right .user-menu a.item")
|
||||
userLinks := htmlDoc.Find(".navbar-right details.dropdown a")
|
||||
assert.Equal(t, 1, userLinks.Length()) // only logout link
|
||||
assert.Equal(t, "Sign out", strings.TrimSpace(userLinks.Text()))
|
||||
|
||||
|
|
@ -212,7 +212,7 @@ func TestGlobalTwoFactorRequirement(t *testing.T) {
|
|||
assert.Equal(t, locale.TrString("settings.must_enable_2fa"), htmlDoc.Find(".ui.red.message").Text())
|
||||
assert.Equal(t, 1, htmlDoc.Find(".navbar-left > a.item").Length()) // only show the Logo, no other links
|
||||
|
||||
userLinks = htmlDoc.Find(".navbar-right .user-menu a.item")
|
||||
userLinks = htmlDoc.Find(".navbar-right details.dropdown a")
|
||||
assert.Equal(t, 1, userLinks.Length()) // only logout link
|
||||
assert.Equal(t, "Sign out", strings.TrimSpace(userLinks.Text()))
|
||||
|
||||
|
|
|
|||
|
|
@ -15,13 +15,13 @@
|
|||
:root details.dropdown {
|
||||
--dropdown-box-shadow: 0 6px 18px var(--color-shadow);
|
||||
--dropdown-item-min-height: 34px;
|
||||
--switch-padding-inline: 0.75rem;
|
||||
--dropdown-padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
@media (pointer: coarse) {
|
||||
:root details.dropdown {
|
||||
--dropdown-item-min-height: 40px;
|
||||
--switch-padding-inline: 1rem;
|
||||
--dropdown-padding-inline: 1rem;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -66,12 +66,12 @@ details.dropdown > summary {
|
|||
}
|
||||
|
||||
details.dropdown > summary:hover,
|
||||
details.dropdown > .content > ul > li:hover {
|
||||
details.dropdown > .content > ul > li > :is(a, button):hover {
|
||||
background: var(--color-hover);
|
||||
}
|
||||
|
||||
details.dropdown[open] > summary,
|
||||
details.dropdown > .content > ul > li:focus-within {
|
||||
details.dropdown > .content > ul > li:focus-within > :is(a, button) {
|
||||
background: var(--color-active);
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +86,7 @@ details.dropdown > .content {
|
|||
margin-top: 0.5rem;
|
||||
|
||||
/* ToDo: upstream to base.css, remove from normalize.css */
|
||||
> hr {
|
||||
hr {
|
||||
height: 1px;
|
||||
margin-block: 0.25rem;
|
||||
background-color: var(--color-secondary);
|
||||
|
|
@ -112,17 +112,24 @@ details.dropdown > .content > ul {
|
|||
/* General styling of list items */
|
||||
details.dropdown > .content > ul > li {
|
||||
width: 100%;
|
||||
background: none;
|
||||
|
||||
> :is(a, button) {
|
||||
padding-block: 0;
|
||||
padding-inline: var(--switch-padding-inline);
|
||||
min-height: var(--dropdown-item-min-height);
|
||||
padding-block: 0;
|
||||
|
||||
width: 100%;
|
||||
padding-inline: var(--dropdown-padding-inline);
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
align-items: center;
|
||||
color: var(--color-text);
|
||||
|
||||
/* Interactable items should be transparent by default. <button> can also have a default background set by the browser */
|
||||
background: none;
|
||||
|
||||
/* Same rounding should apply to both <li> and it's items with paintable backgrounds */
|
||||
border-radius: inherit;
|
||||
|
||||
/* Suppress underline - hover is indicated by background color */
|
||||
text-decoration: none;
|
||||
|
||||
|
|
@ -132,11 +139,22 @@ details.dropdown > .content > ul > li {
|
|||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Suppress default styling of <button> */
|
||||
> button {
|
||||
background: none;
|
||||
}
|
||||
/* Special styling for "headers" */
|
||||
/* A few dropdowns contain such headers, however, they are not semantically considered as headers */
|
||||
details.dropdown > .content > .header {
|
||||
display: flex;
|
||||
padding-block: 0.5rem;
|
||||
padding-inline: var(--dropdown-padding-inline);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-transform: uppercase;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
/* Decrease bottom padding if an <hr> follows, which adds it's own vertical padding */
|
||||
details.dropdown > .content > .header:has(+ hr) {
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
/* dir-auto option - switch the direction at a width point where most of layout changes occur */
|
||||
|
|
@ -144,7 +162,7 @@ details.dropdown > .content > ul > li {
|
|||
details.dropdown.dir-auto > .content {
|
||||
inset-inline: 0 auto;
|
||||
direction: rtl;
|
||||
> ul > li {
|
||||
> :is(span, ul) {
|
||||
direction: ltr;
|
||||
}
|
||||
}
|
||||
|
|
@ -154,7 +172,7 @@ details.dropdown > .content > ul > li {
|
|||
details.dropdown.dir-rtl > .content {
|
||||
inset-inline: 0 auto;
|
||||
direction: rtl;
|
||||
> ul > li {
|
||||
> :is(span, ul) {
|
||||
direction: ltr;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,7 +42,8 @@
|
|||
}
|
||||
|
||||
#navbar a.item:hover, #navbar a.item:focus,
|
||||
#navbar div.ui.dropdown:hover,
|
||||
#navbar details.dropdown > summary:is(:hover, :focus),
|
||||
#navbar details.dropdown[open] > summary,
|
||||
#navbar button.item:hover, #navbar button.item:focus {
|
||||
background: var(--color-nav-hover-bg);
|
||||
}
|
||||
|
|
@ -95,6 +96,21 @@
|
|||
width: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
#navbar details.dropdown {
|
||||
display: none;
|
||||
}
|
||||
#navbar.navbar-menu-open details.dropdown {
|
||||
display: block;
|
||||
width: 100%;
|
||||
> summary {
|
||||
width: 100%;
|
||||
justify-content: start;
|
||||
}
|
||||
> .content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
#navbar.navbar-menu-open .navbar-left #navbar-logo {
|
||||
justify-content: flex-start;
|
||||
width: auto;
|
||||
|
|
@ -109,6 +125,12 @@
|
|||
}
|
||||
}
|
||||
|
||||
#navbar details.dropdown summary {
|
||||
padding-inline-start: 0.75rem;
|
||||
/* The min height is dictated by the largest dropdown, which is the one that has an avatar in the opener */
|
||||
min-height: 38px;
|
||||
}
|
||||
|
||||
#navbar a.item .notification_count {
|
||||
color: var(--color-nav-bg);
|
||||
padding: 0 3.75px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue