feat(workspace): move page Back buttons into the header and add a history fallback (#1814)

This commit is contained in:
4gray authored and GitHub committed 2026-10-04 12:16:39 +02:00
1 parent 6f247fb538
commit 84aef83a6c
40 files changed
+902 -216

No files matched your search

+8 -4
View File
@@ -406,17 +406,21 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
)
)
.toBe(0.25);
// M3U has no browse Back target, so the header shows no arrow in either
// state; the now-playing bar's own Close button returns to browse.
// M3U registers no Back target in either state: the header's only arrow
// is the history fallback to the dashboard the import started from,
// which claims no Escape. The now-playing bar's own Close button
// returns to browse.
const shell = detail(page).locator('app-portal-detail-shell');
const headerBack = page.locator('[data-test-id="workspace-header-back"]');
await expect(headerBack).toHaveCount(0);
await expect(headerBack).toHaveCount(1);
await expect(headerBack).not.toHaveAttribute('aria-keyshortcuts');
await shell
.locator('app-portal-inline-player')
.getByRole('button', { name: 'Close player', exact: true })
.click();
await expect(inlineVideo(page)).toHaveCount(0);
await expect(headerBack).toHaveCount(0);
await expect(headerBack).toHaveCount(1);
await expect(headerBack).not.toHaveAttribute('aria-keyshortcuts');
// The hero keeps its own inset (32px, or 20px in a pane narrower than
// 760px) in both states.
expect(
+30 -12
View File
@@ -21,8 +21,9 @@ import {
* by default so the content keeps the full viewport width, opened from
* the header toggle (winning over the persisted desktop inline width),
* and closed again by picking a category or tapping the backdrop.
* 4. The settings section list scrolls instead of painting over the
* Back footer — now inside the open drawer.
* 4. Settings keeps its drawer toggle beside the header Back (the drawer
* holds the sections), and the section list scrolls inside the drawer.
* On a portal list, the header's history Back yields to the toggle.
* 5. On a 640x360 landscape phone the live route keeps the channel
* sidebar at least 72px tall and the player container inside the
* viewport.
@@ -107,17 +108,25 @@ test.describe('portrait phone 375x812', () => {
await expectRailLinksInsideTopBar(page);
});
test('@mobile settings drawer opens from the header toggle and keeps the section list clear of the Back footer', async ({
test('@mobile settings keeps the drawer toggle beside the header Back and the section list inside the drawer', async ({
page,
}) => {
await page.goto('/workspace/settings');
// Back is the header's leading button. The drawer holds the section
// list, so its toggle stays beside Back instead of giving way.
const back = page.locator('[data-test-id="workspace-header-back"]');
const toggle = page.locator('[data-test-id="context-drawer-toggle"]');
await expect(back).toBeVisible();
await expect(toggle).toBeVisible();
expect((await boxOf(back)).x).toBeLessThan((await boxOf(toggle)).x);
// The phone context panel is an off-canvas drawer: hidden until the
// header toggle opens it, so the settings content owns the pane.
const panel = page.locator('.context-panel--settings');
await expect(panel).toBeHidden();
await page.locator('[data-test-id="context-drawer-toggle"]').click();
await toggle.click();
await expect(panel).toBeVisible();
// Narrower than the viewport so the backdrop stays tappable, and
@@ -126,15 +135,13 @@ test.describe('portrait phone 375x812', () => {
expect(panelBox.width).toBeGreaterThanOrEqual(300);
expect(panelBox.width).toBeLessThanOrEqual(PHONE.width - 20);
const footer = panel.locator('.settings-panel-footer');
await expect(footer.locator('.settings-back-button')).toBeVisible();
// Before #1326 the section list kept its full content height and
// painted over the footer whenever the panel was shorter than its
// sections; now the list scrolls and ends above the footer.
// No footer Back any more; the list scrolls and ends inside the
// panel instead of painting past it (#1326).
await expect(panel.locator('button:has-text("Back")')).toHaveCount(0);
const listBox = await boxOf(panel.locator('.settings-sections-list'));
const footerBox = await boxOf(footer);
expect(listBox.y + listBox.height).toBeLessThanOrEqual(footerBox.y + 1);
expect(listBox.y + listBox.height).toBeLessThanOrEqual(
panelBox.y + panelBox.height + 1
);
// Tapping the backdrop (right of the drawer) closes it.
await page
@@ -163,7 +170,18 @@ test.describe('xtream portal routes on a phone', () => {
test('@mobile @xtream vod route keeps the context panel in a drawer behind the header toggle', async ({
page,
}) => {
// The import navigated here from the dashboard, so the header offers
// the history Back on wide screens...
const back = page.locator('[data-test-id="workspace-header-back"]');
await expect(back).toBeVisible();
await page.setViewportSize(PHONE);
// ...and on a phone it yields to the drawer toggle, the list's only
// way into its categories.
await expect(back).toBeHidden();
await expect(
page.locator('[data-test-id="context-drawer-toggle"]')
).toBeVisible();
// Hidden by default — the content owns the full pane. This is the
// successor to the #1326 stacked layout, which left the content
+12 -2
View File
@@ -7,7 +7,12 @@ async function openSettings(page: Page) {
// The bare settings URL redirects to the default section page.
await page.waitForURL(/\/workspace\/settings\/general$/);
await expect(page.locator('.settings-container')).toBeVisible();
await expect(page.locator('.settings-back-button')).toBeVisible();
await expect(settingsBack(page)).toBeVisible();
}
/** Settings' Back is the workspace header's leading button. */
function settingsBack(page: Page) {
return page.locator('[data-test-id="workspace-header-back"]');
}
/** Settings render one section page at a time — open it via the rail. */
@@ -36,7 +41,12 @@ test.describe('Settings', () => {
test('@settings @web Check settings page', async ({ page }) => {
await openSettings(page);
await page.locator('.settings-back-button').click();
// The context panel no longer carries a Back of its own.
await expect(
page.locator('app-workspace-settings-context-panel button')
).toHaveCount(0);
await settingsBack(page).click();
await page.waitForURL(/\/workspace\/dashboard$/);
});
test('@settings @web Change video player', async ({ page }) => {
@@ -0,0 +1,86 @@
import type { Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
addXtreamPortal,
interceptXtreamRequests,
MOCK_SERVER,
} from './xtream-series-playback.fixture';
/**
* Pages reached from a detail or the header search draw no Back arrow of
* their own: they register the workspace header's leading Back, which keeps
* their previous return behaviour (history Back).
* Contract: docs/architecture/workspace-shell.md, "Header Back".
*/
const headerBack = (page: Page) =>
page.locator(
'app-workspace-shell-header [data-test-id="workspace-header-back"]'
);
/** The one Back button on the page, wherever it lives. */
const anyBack = (page: Page) =>
page.getByRole('button', { name: 'Back', exact: true });
async function expectOnlyHeaderBack(page: Page): Promise<void> {
await expect(headerBack(page)).toBeVisible();
await expect(anyBack(page)).toHaveCount(1);
// None of these pages handles Escape.
await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts');
}
test.beforeEach(async ({ page, request }) => {
await request.post(`${MOCK_SERVER}/reset`);
await page.goto('/');
await interceptXtreamRequests(page);
await addXtreamPortal(page);
});
test('@web @xtream the in-portal search page returns through the header Back', async ({
page,
}) => {
await page
.locator('app-workspace-shell-rail a[href$="/workspace/dashboard"]')
.first()
.click();
await page.waitForURL(/\/workspace\/dashboard$/);
// The rail link's tooltip would otherwise sit over the header's leading
// button for as long as the pointer rests on the link.
await page.mouse.move(640, 400);
// Enter on the dashboard opens the active portal's search page.
const search = page.locator(
'app-workspace-shell-header .search-field input[type="search"]'
);
await search.fill('Movie');
await search.press('Enter');
await page.waitForURL(/\/workspace\/xtreams\/[^/]+\/search\?q=Movie$/);
await expect(page.locator('app-search-layout')).toBeVisible();
await expectOnlyHeaderBack(page);
await headerBack(page).click();
await page.waitForURL(/\/workspace\/dashboard$/);
});
for (const { name, path, selector } of [
{
name: 'Discover',
path: 'discover?type=movie&genre=18&genreLabel=Drama',
selector: 'app-discover-view',
},
{ name: 'actor', path: 'actor/287', selector: 'app-actor-view' },
]) {
test(`@web @xtream the ${name} page returns through the header Back`, async ({
page,
}) => {
const listUrl = page.url();
const portalUrl = listUrl.replace(/\/vod.*$/, '');
await page.goto(`${portalUrl}/${path}`);
await expect(page.locator(selector)).toBeAttached();
await expectOnlyHeaderBack(page);
await headerBack(page).click();
await page.waitForURL(listUrl);
});
}