mirror of
https://codeberg.org/forgejo/forgejo.git
synced 2026-05-12 22:10:25 +00:00
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>
229 lines
9.6 KiB
TypeScript
229 lines
9.6 KiB
TypeScript
// Copyright 2025 The Forgejo Authors. All rights reserved.
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
// @watch start
|
|
// templates/shared/user/actions_menu.tmpl
|
|
// templates/org/header.tmpl
|
|
// templates/explore/search.tmpl
|
|
// web_src/js/modules/dropdown.ts
|
|
// @watch end
|
|
|
|
import {expect} from '@playwright/test';
|
|
import {test} from './utils_e2e.ts';
|
|
|
|
test('JS enhanced interaction', 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 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();
|
|
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();
|
|
// Open with Enter, close with Space
|
|
await dropdownSummary.press(`Enter`);
|
|
await expect(dropdownContent).toBeVisible();
|
|
await dropdownSummary.press(`Space`);
|
|
await expect(dropdownContent).toBeHidden();
|
|
// Open with Space, close with Enter
|
|
await dropdownSummary.press(`Space`);
|
|
await expect(dropdownContent).toBeVisible();
|
|
await dropdownSummary.press(`Enter`);
|
|
await expect(dropdownContent).toBeHidden();
|
|
// Open with Enter, close with Enter
|
|
await dropdownSummary.press(`Enter`);
|
|
await expect(dropdownContent).toBeVisible();
|
|
await dropdownSummary.press(`Escape`);
|
|
await expect(dropdownContent).toBeHidden();
|
|
|
|
// Open and navigate with ArrowDown, close with Tab
|
|
await dropdownSummary.focus();
|
|
await dropdown.press(`ArrowDown`);
|
|
await expect(page.locator(`a[href$=".rss"]`)).toBeFocused();
|
|
await dropdown.press(`ArrowDown`);
|
|
await expect(page.locator(`a[href$=".atom"]`)).toBeFocused();
|
|
await dropdown.press(`ArrowDown`);
|
|
await expect(page.locator(`a[href$=".keys"]`)).toBeFocused();
|
|
await dropdown.press(`ArrowDown`);
|
|
await expect(page.locator(`a[href$=".gpg"]`)).toBeFocused();
|
|
// ArrowDown won't move us farther than the last dropdown item
|
|
await dropdown.press(`ArrowDown`);
|
|
await expect(page.locator(`a[href$=".gpg"]`)).toBeFocused();
|
|
// Pressing Tab on last item will move us away from the dropdown and close the dropdown
|
|
await dropdown.press(`Tab`);
|
|
await expect(dropdownContent).toBeHidden();
|
|
|
|
// Navigate and close with Shift+Tab
|
|
await dropdownSummary.focus();
|
|
await dropdown.press(`Enter`);
|
|
await expect(dropdownSummary).toBeFocused();
|
|
await dropdown.press(`ArrowDown`);
|
|
await expect(page.locator(`a[href$=".rss"]`)).toBeFocused();
|
|
await dropdown.press('Shift+Tab');
|
|
await expect(dropdownSummary).toBeFocused();
|
|
await dropdown.press('Shift+Tab');
|
|
await expect(dropdownContent).toBeHidden();
|
|
|
|
// Navigate with ArrowUp
|
|
await dropdownSummary.focus();
|
|
await dropdown.press(`ArrowDown`);
|
|
await expect(page.locator(`a[href$=".rss"]`)).toBeFocused();
|
|
await dropdown.press(`ArrowDown`);
|
|
await expect(page.locator(`a[href$=".atom"]`)).toBeFocused();
|
|
await dropdown.press(`ArrowUp`);
|
|
await expect(page.locator(`a[href$=".rss"]`)).toBeFocused();
|
|
// Pressing ArrowUp on first item will move us to summary, but no farther from here
|
|
await dropdown.press(`ArrowUp`);
|
|
await expect(dropdownSummary).toBeFocused();
|
|
await dropdown.press(`Escape`);
|
|
await expect(dropdownContent).toBeHidden();
|
|
|
|
// Open and then 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 interaction', 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 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();
|
|
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
|
|
// Open with Enter, close with Space
|
|
await dropdownSummary.press(`Enter`);
|
|
await expect(dropdownContent).toBeVisible();
|
|
await dropdownSummary.press(`Space`);
|
|
await expect(dropdownContent).toBeHidden();
|
|
// Open with Space, close with Enter
|
|
await dropdownSummary.press(`Space`);
|
|
await expect(dropdownContent).toBeVisible();
|
|
await dropdownSummary.press(`Enter`);
|
|
await expect(dropdownContent).toBeHidden();
|
|
// Closing by Escape is not possible w/o JS enhancements
|
|
await dropdownSummary.press(`Enter`);
|
|
await expect(dropdownContent).toBeVisible();
|
|
await dropdownSummary.press(`Escape`);
|
|
await expect(dropdownContent).toBeVisible();
|
|
});
|
|
|
|
test.describe(`Visual properties`, () => {
|
|
async function evaluateDropdownItems(page, selector, direction, height) {
|
|
const computedStyles = await page.locator(selector).evaluateAll((items) =>
|
|
items.map((item) => {
|
|
const s = getComputedStyle(item);
|
|
return {
|
|
direction: s.direction,
|
|
height: s.height,
|
|
};
|
|
}),
|
|
);
|
|
for (const cs of computedStyles) {
|
|
expect(cs.direction).toBe(direction);
|
|
expect(cs.height).toBe(height);
|
|
}
|
|
}
|
|
|
|
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 page.locator(`${selectorPrefix} > .content`).evaluate((el) => getComputedStyle(el).direction)).toBe('rtl');
|
|
await evaluateDropdownItems(page, `${selectorPrefix} > .content > ul > li`, 'ltr', isMobile ? '40px' : '34px');
|
|
|
|
// 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)');
|
|
});
|
|
});
|