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

@@ -24,6 +24,11 @@ import {
// The "Episodes" heading must also stay on one line: the detail pane is far
// narrower than the window beside the rail and category panel, and the
// heading used to wrap beside its actions.
//
// Pages without a Back of their own (the list a detail returns to) get the
// header's history fallback while an in-app previous page exists; on a
// phone it yields to the drawer toggle, and with nowhere to go the slot is
// empty rather than a disabled arrow.
// ---------------------------------------------------------------------------
const widths = [1280, 780, 375];
@@ -45,6 +50,16 @@ function headerBack(page: Page): Locator {
return page.getByTestId('workspace-header-back');
}
/**
* The generic history Back. A detail's own Back advertises Escape in browse;
* the fallback runs no page handler, so it advertises none.
*/
async function expectHistoryBack(page: Page): Promise<void> {
await expect(headerBack(page)).toBeVisible();
await expect(headerBack(page)).toHaveAccessibleName('Back');
await expect(headerBack(page)).not.toHaveAttribute('aria-keyshortcuts');
}
/** Line boxes of the heading's text; 1 means it did not wrap. */
function headingLineCount(page: Page): Promise<number> {
return page
@@ -228,12 +243,15 @@ async function startFirstEpisode(page: Page): Promise<void> {
).toBeVisible({ timeout: 20_000 });
}
/** The header Back leaves the detail and is gone from the list it opens. */
/**
* The header Back leaves the detail; the list it opens keeps only the
* history fallback (it was itself reached by navigation).
*/
async function expectHeaderBackReturnsToList(page: Page): Promise<void> {
await headerBack(page).click();
await expect(page).not.toHaveURL(detailUrlPattern);
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
await expect(headerBack(page)).toHaveCount(0);
await expectHistoryBack(page);
}
test.describe('Portal detail header Back', () => {
@@ -248,7 +266,7 @@ test.describe('Portal detail header Back', () => {
const page = app.mainWindow;
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
await expect(headerBack(page)).toHaveCount(0);
await expectHistoryBack(page);
const detailUrl = await openFirstSeries(page);
await expectBackInHeader(page, 'browse');
@@ -287,4 +305,45 @@ test.describe('Portal detail header Back', () => {
await closeElectronApp(app);
}
});
test('@xtream @electron falls back to history where no page offers Back', async ({
dataDir,
request,
}) => {
await resetMockServers(request, ['xtream']);
const app = await launchElectronApp(dataDir);
try {
const page = app.mainWindow;
await page.waitForURL(/\/workspace\//);
const startUrl = page.url();
// The first page of the session has nowhere to go back to: the
// slot is empty, not a disabled arrow.
await expect(headerBack(page)).toHaveCount(0);
await addXtreamPortal(page);
await waitForXtreamWorkspaceReady(page);
const listUrl = page.url();
await expectHistoryBack(page);
// On a phone the list's drawer toggle keeps the slot: it is the
// only way into the categories.
await page.setViewportSize({ width: 375, height: 800 });
await expect(page.getByTestId('context-drawer-toggle')).toBeVisible();
await expect(headerBack(page)).toBeHidden();
await page.setViewportSize({ width: widths[0], height: 800 });
await headerBack(page).click();
await expect(page).toHaveURL(startUrl);
await expect(headerBack(page)).toHaveCount(0);
// Forward history is not offered; browser Forward still works and
// brings the fallback back.
await page.goForward();
await expect(page).toHaveURL(listUrl);
await expectHistoryBack(page);
} finally {
await closeElectronApp(app);
}
});
});
@@ -638,6 +638,18 @@ test.describe('Electron Workspace Search', () => {
await expect(
xtreamSearchResultCards(app.mainWindow).first()
).toBeVisible({ timeout: 20000 });
// The search page's Back is the header's leading button, not an
// arrow beside its title, and it returns to the dashboard.
const headerBack = app.mainWindow.getByTestId(
'workspace-header-back'
);
await expect(headerBack).toBeVisible();
await expect(
app.mainWindow.getByRole('button', { name: 'Back', exact: true })
).toHaveCount(1);
await headerBack.click();
await expectPathname(app.mainWindow, /\/workspace\/dashboard$/);
} finally {
await closeElectronApp(app);
}
+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);
});
}