From 84aef83a6c907a905c9e62dbbb40e9a040d7783d Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Sun, 4 Oct 2026 12:16:39 +0200 Subject: [PATCH] feat(workspace): move page Back buttons into the header and add a history fallback (#1814) --- .changes/workspace-header-back-everywhere.md | 6 + .../src/detail-header-back.e2e.ts | 65 ++++++++- apps/electron-backend-e2e/src/search.e2e.ts | 12 ++ apps/web-e2e/src/m3u-movie-details.e2e.ts | 12 +- apps/web-e2e/src/mobile-layout.e2e.ts | 42 ++++-- apps/web-e2e/src/settings.e2e.ts | 14 +- apps/web-e2e/src/workspace-header-back.e2e.ts | 86 ++++++++++++ docs/architecture/iptvnator-ui-guidelines.md | 17 ++- docs/architecture/portal-detail-navigation.md | 47 ++++--- docs/architecture/workspace-shell.md | 72 +++++++++- libs/portal/shared/data-access/src/index.ts | 1 + .../src/lib/register-workspace-back.spec.ts | 54 +++++++ .../src/lib/register-workspace-back.ts | 39 ++++++ .../workspace-back-navigation.service.spec.ts | 132 +++++++++++++++++- .../lib/workspace-back-navigation.service.ts | 79 ++++++++++- .../search-layout.component.html | 14 +- .../search-layout.component.scss | 12 -- .../search-layout.component.spec.ts | 38 +++-- .../search-layout/search-layout.component.ts | 22 ++- .../util/src/lib/workspace-back-target.ts | 15 ++ .../stalker-search.component.html | 2 +- .../search-results.component.html | 2 +- .../search-results.component.ts | 9 +- .../portal-detail-shell.component.ts | 20 +-- .../lib/actor-view/actor-view.component.html | 9 -- .../lib/actor-view/actor-view.component.scss | 18 --- .../actor-view/actor-view.component.spec.ts | 28 ++++ .../lib/actor-view/actor-view.component.ts | 8 +- .../discover-view.component.html | 9 -- .../discover-view.component.scss | 18 --- .../discover-view.component.spec.ts | 37 +++++ .../discover-view/discover-view.component.ts | 8 +- libs/ui/shared-portals/tsconfig.spec.json | 2 +- ...pace-settings-context-panel.component.scss | 26 ++-- ...e-settings-context-panel.component.spec.ts | 25 ++++ ...kspace-settings-context-panel.component.ts | 27 ++-- .../workspace-shell-header.component.html | 13 +- .../workspace-shell-header.component.scss | 6 + .../workspace-shell-header.component.spec.ts | 56 +++++++- .../workspace-shell-header.component.ts | 16 ++- 40 files changed, 902 insertions(+), 216 deletions(-) create mode 100644 .changes/workspace-header-back-everywhere.md create mode 100644 apps/web-e2e/src/workspace-header-back.e2e.ts create mode 100644 libs/portal/shared/data-access/src/lib/register-workspace-back.spec.ts create mode 100644 libs/portal/shared/data-access/src/lib/register-workspace-back.ts create mode 100644 libs/ui/shared-portals/src/lib/actor-view/actor-view.component.spec.ts create mode 100644 libs/ui/shared-portals/src/lib/discover-view/discover-view.component.spec.ts diff --git a/.changes/workspace-header-back-everywhere.md b/.changes/workspace-header-back-everywhere.md new file mode 100644 index 000000000..f3994462f --- /dev/null +++ b/.changes/workspace-header-back-everywhere.md @@ -0,0 +1,6 @@ +--- +type: feature +area: workspace +--- + +Back is now always in the same place: Settings, search, Discover and actor pages use the Back button at the start of the top bar instead of their own. Any page you reached from another one also shows it, and it takes you to the previous page. diff --git a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts index 2f8a4ec57..7be2f2e85 100644 --- a/apps/electron-backend-e2e/src/detail-header-back.e2e.ts +++ b/apps/electron-backend-e2e/src/detail-header-back.e2e.ts @@ -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 { + 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 { return page @@ -228,12 +243,15 @@ async function startFirstEpisode(page: Page): Promise { ).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 { 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); + } + }); }); diff --git a/apps/electron-backend-e2e/src/search.e2e.ts b/apps/electron-backend-e2e/src/search.e2e.ts index 257c903f3..e3a97d7a3 100644 --- a/apps/electron-backend-e2e/src/search.e2e.ts +++ b/apps/electron-backend-e2e/src/search.e2e.ts @@ -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); } diff --git a/apps/web-e2e/src/m3u-movie-details.e2e.ts b/apps/web-e2e/src/m3u-movie-details.e2e.ts index 2f2a49a41..b28e80cae 100644 --- a/apps/web-e2e/src/m3u-movie-details.e2e.ts +++ b/apps/web-e2e/src/m3u-movie-details.e2e.ts @@ -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( diff --git a/apps/web-e2e/src/mobile-layout.e2e.ts b/apps/web-e2e/src/mobile-layout.e2e.ts index c0535cdad..e58f9cb9e 100644 --- a/apps/web-e2e/src/mobile-layout.e2e.ts +++ b/apps/web-e2e/src/mobile-layout.e2e.ts @@ -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 diff --git a/apps/web-e2e/src/settings.e2e.ts b/apps/web-e2e/src/settings.e2e.ts index e12bb9d7b..b40e6fb61 100644 --- a/apps/web-e2e/src/settings.e2e.ts +++ b/apps/web-e2e/src/settings.e2e.ts @@ -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 }) => { diff --git a/apps/web-e2e/src/workspace-header-back.e2e.ts b/apps/web-e2e/src/workspace-header-back.e2e.ts new file mode 100644 index 000000000..6bdb5f175 --- /dev/null +++ b/apps/web-e2e/src/workspace-header-back.e2e.ts @@ -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 { + 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); + }); +} diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 1e6ebff09..53e89559c 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -189,6 +189,18 @@ scrim after three idle seconds, with a 32px mute toggle in the corner. It never starts under `prefers-reduced-motion` or with `saveData`, and stops while the hero is off screen or the window is unfocused. +## Back Navigation + +Page-level Back lives only in the workspace header's leading slot (see +[Header Back](./workspace-shell.md#header-back)). A routed page, or the shell +it renders in, registers it with `registerWorkspaceBack()` instead of drawing +an arrow, so Back keeps one position and one look on every page and never +floats over a scroll owner. Without a registration the header falls back to +browser history while an in-app previous page exists, and shows nothing +otherwise. An arrow that returns within a menu, dialog or player panel is not +page navigation and stays in that surface; an error state may repeat the +header's Back as a labelled recovery button beside its other actions. + ## Electron Drag Regions Every interactive descendant of a drag region—including buttons, links, @@ -890,7 +902,10 @@ Prefer removing a control over shrinking everything around it: - Counts and subtitles that a neighbouring control already states. Never drop the only way back to a hidden surface. A collapse toggle that is -reachable by touch needs its restore affordance to be reachable too. +reachable by touch needs its restore affordance to be reachable too. For the +same reason the header's history Back yields to the context drawer toggle, +and Settings keeps the toggle beside its Back; only a detail page's Back, +whose list shows the toggle again, takes the toggle's slot. ## Typography diff --git a/docs/architecture/portal-detail-navigation.md b/docs/architecture/portal-detail-navigation.md index 4ce2e3f1c..e9aa03a65 100644 --- a/docs/architecture/portal-detail-navigation.md +++ b/docs/architecture/portal-detail-navigation.md @@ -23,27 +23,29 @@ do not reach global player shortcuts. Descendant controls retain their native keys and Tab order. Entering watch still scrolls to the top; Back and saved catalog scroll positions retain the existing navigation contract below. -The shell owns the page's Back action, but the workspace header renders it. -While `backAvailable()` is true, the shell registers a target with -`WorkspaceBackNavigationService` (`@iptvnator/portal/shared/data-access`). The -target, the `WorkspaceBackTarget` contract in `@iptvnator/portal/shared/util`, -carries a label (the host's `backLabel`, else the translated "Back"), whether -Escape currently runs it, and `run()`, which emits `backClicked`. The service -keeps a stack in which the newest registration wins, and each release removes -only its own target. A loading shell replaced by the loaded one therefore -cannot clear its successor, whichever is destroyed first. The header shows the target as an `arrow_back` -icon button in its leading slot (`data-test-id="workspace-header-back"`), to -the right of the macOS traffic lights. That is where desktop apps and Material's -top app bar keep navigation. The header never scrolls, so the control stays -visible over long episode lists, and nothing floats over the scroll owner: -detail columns keep symmetric insets and their full width. The button has -Electron `no-drag` hit testing. In browse its tooltip and `aria-keyshortcuts` +The shell owns the page's Back action, but the workspace header renders it +(the general contract, other pages and the history fallback are in +[Header Back](./workspace-shell.md#header-back)). While `backAvailable()` is +true, the shell registers a target through `registerWorkspaceBack()` +(`@iptvnator/portal/shared/data-access`). It carries the host's `backLabel` +(else the translated "Back"), whether Escape currently runs it, and `run()`, +which emits `backClicked`. The newest registration wins and each release +removes only its own target, so a loading shell replaced by the loaded one +cannot clear its successor, whichever is destroyed first. The header shows the +target as an `arrow_back` icon button in its leading slot +(`data-test-id="workspace-header-back"`), to the right of the macOS traffic +lights. That is where desktop apps and Material's top app bar keep navigation. +The header never scrolls, so the control stays visible over long episode +lists, and nothing floats over the scroll owner: detail columns keep symmetric +insets and their full width. In browse its tooltip and `aria-keyshortcuts` advertise Escape, and an Escape pressed on the focused button runs Back itself, because the shell's browse Escape requires focus inside the page. At ≤640 px Back takes the context drawer toggle's slot (one navigation icon); the -list it returns to shows the toggle again. This replaced #1763's 72 px lane -reserved beside a sticky in-page arrow, along with its phone bar. Electron E2E -`detail-header-back.e2e.ts` covers 1280, 780 and 375 px in browse and watch. +list it returns to shows the toggle again, and there the history fallback +yields to it. This replaced #1763's 72 px lane reserved beside a sticky +in-page arrow, along with its phone bar. Electron E2E +`detail-header-back.e2e.ts` covers 1280, 780 and 375 px in browse and watch, +and the history fallback on the list Back returns to. The header Back is route-level in both states: it emits `backClicked` whether or not inline playback is active, so the arrow keeps one meaning and @@ -67,8 +69,13 @@ keep working on the page. Hosts without browse navigation set `backAvailable=false`: M3U uses its channel sidebar, and collection bootstrap placeholders have no return handler. They register no header Back in either state and have no browse Escape action; their -watch exits are the bar's Close player button and Escape. Loading/error shells -with a return handler keep Back available. +watch exits are the bar's Close player button and Escape. The header may still +show the history fallback there (a generic Back to the previous page, without +Escape) when the page was reached by in-app navigation. Loading/error shells +with a return handler keep Back available. The downloads offline and recording +error states additionally keep a labelled "Back to Downloads" button beside +Retry or Remove: it is the error state's recovery action and runs the same +handler as the header Back. ## Summary diff --git a/docs/architecture/workspace-shell.md b/docs/architecture/workspace-shell.md index 31eaaec18..44f4d2771 100644 --- a/docs/architecture/workspace-shell.md +++ b/docs/architecture/workspace-shell.md @@ -93,10 +93,9 @@ The shell is intentionally split into four persistent regions: 4. No brand mark: it only repeated the first workspace link (Dashboard, or Sources when the dashboard is off). 2. Top header: - 1. Leading Back slot, shown while the current page registers a target - with `WorkspaceBackNavigationService` (detail pages today). At - ≤640 px it takes the context drawer toggle's place. See - [Portal Detail Navigation](./portal-detail-navigation.md). + 1. Leading Back slot: the current page's registered Back, else browser + history while an in-app previous page exists, else nothing. See + [Header Back](#header-back). 2. Playlist switcher. 3. Route-aware search input and command palette trigger. 4. Add source action. @@ -130,6 +129,71 @@ When adding shell behavior, prefer placing it in the service that owns the nearest existing state. Keep `WorkspaceShellFacade` as a stable re-export layer for the template unless the template contract itself intentionally changes. +## Header Back + +The header's leading slot is the workspace's one page-level Back. Pages do not +render an arrow of their own: they register a `WorkspaceBackTarget` +(`@iptvnator/portal/shared/util`) with `WorkspaceBackNavigationService` +(`@iptvnator/portal/shared/data-access`), normally through +`registerWorkspaceBack()`, which registers for the calling component's +lifetime while its optional `available` predicate holds. The newest +registration wins, and each release removes only its own target. The button +(`data-test-id="workspace-header-back"`) sits beside the macOS traffic lights, +never scrolls, and has Electron `no-drag` hit testing. A target supplies its +label (else the translated "Back"), whether Escape on the page runs it, and +`run()`. + +| Page | Registered by | Back runs | ≤640 px | +| --- | --- | --- | --- | +| Portal, collection, offline and recording details | `PortalDetailShellComponent` while `backAvailable()` | the host's `backClicked` | replaces the drawer toggle | +| Xtream and Stalker Discover and actor pages | `DiscoverViewComponent`, `ActorViewComponent` | the route's `Location.back()` | (no drawer) | +| In-portal search, Xtream and Stalker | `SearchLayoutComponent` while `backAvailable()` and no inline detail replaces the results | `Location.back()` | (no drawer) | +| Settings | `WorkspaceSettingsContextPanelComponent`, which exists exactly while the settings route shows | `Location.back()` | beside the drawer toggle | + +Detail-page semantics (Escape, browse and watch) are in +[Portal Detail Navigation](./portal-detail-navigation.md#detail-scroll-and-focus). + +**History fallback.** Without a registration, the header shows Back while the +previous history entry is an in-app one, and runs `Location.back()`. The +service reads that from the Navigation API: the previous entry must be +same-document (`NavigationHistoryEntry.sameDocument`), so the router pushed it +after this document loaded. Entries from before a reload or from another page +of the origin never count, and the fallback can neither leave nor reload the +app. `currententrychange` keeps it current through pushes, replacements, +traversals and guard-cancelled Back navigations that the router rewrites. +Without the Navigation API (older Safari and Firefox, jsdom) there is no +fallback; registered pages are unaffected. The fallback reads "Back" and +advertises no Escape, because no page handles one for it. Pages that set +`backAvailable=false`, such as M3U details, therefore show it too when they +were reached by navigation. + +When there is nowhere to go, the slot is empty rather than a disabled arrow. +Sessions often start on a page that never navigates (an M3U playlist or live +TV), where a disabled arrow would stay for the whole session. The cost is one +shift of the switcher and search when Back first appears or leaves, which +happens only at the start of the history and together with a route change. +There is no Forward button: Stalker inline details are store state, not +history entries, so Forward would skip them. + +**Phone width.** `phoneDrawerToggle` sets how Back shares the leading slot with +the context drawer toggle. `replace` (the default) takes the toggle's slot: a +detail page's drawer belongs to the list that Back returns to. `beside` keeps +both: the settings drawer holds the page's own sections. `yield` hides Back +while the toggle shows: the history fallback must not cost a category list its +only way into the drawer, and two navigation icons do not fit beside the +switcher. System and browser Back still work there. + +**Left in place.** These controls stay inside their surface on purpose: + +1. Downloads offline and recording detail error states keep their labelled + "Back to Downloads" button beside Retry or Remove. It is the error state's + recovery action, not page chrome; the header shows the same Back. +2. Back controls internal to a surface, which leave a panel rather than the + page: the Embedded MPV dock panel and the alternative-sources panel inside + the VOD "…" menu. +3. The M3U player sidebar's Home button, which renders only outside the + workspace shell. + ## Context Panel Rules The shell decides which secondary panel to show from the current route: diff --git a/libs/portal/shared/data-access/src/index.ts b/libs/portal/shared/data-access/src/index.ts index f34f8b9d1..eb5318d98 100644 --- a/libs/portal/shared/data-access/src/index.ts +++ b/libs/portal/shared/data-access/src/index.ts @@ -7,4 +7,5 @@ export * from './lib/source-health.service'; export * from './lib/source-cleanup.service'; +export * from './lib/register-workspace-back'; export * from './lib/workspace-back-navigation.service'; diff --git a/libs/portal/shared/data-access/src/lib/register-workspace-back.spec.ts b/libs/portal/shared/data-access/src/lib/register-workspace-back.spec.ts new file mode 100644 index 000000000..ff0295ca7 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/register-workspace-back.spec.ts @@ -0,0 +1,54 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { registerWorkspaceBack } from './register-workspace-back'; +import { WorkspaceBackNavigationService } from './workspace-back-navigation.service'; + +@Component({ template: '' }) +class PageComponent { + readonly available = signal(true); + readonly run = jest.fn(); + + constructor() { + registerWorkspaceBack({ + available: this.available, + phoneDrawerToggle: 'beside', + run: () => this.run(), + }); + } +} + +describe('registerWorkspaceBack', () => { + function setup() { + TestBed.resetTestingModule(); + const fixture = TestBed.createComponent(PageComponent); + fixture.detectChanges(); + const backNavigation = TestBed.inject(WorkspaceBackNavigationService); + return { fixture, backNavigation, page: fixture.componentInstance }; + } + + it('offers a generic Back without Escape that runs the page handler', () => { + const { backNavigation, page } = setup(); + const target = backNavigation.target(); + + expect(target?.label()).toBeNull(); + expect(target?.escapeShortcut()).toBe(false); + expect(target?.phoneDrawerToggle).toBe('beside'); + expect(backNavigation.goBack()).toBe(true); + expect(page.run).toHaveBeenCalledTimes(1); + }); + + it('registers only while available and releases with the page', () => { + const { fixture, backNavigation, page } = setup(); + + page.available.set(false); + TestBed.tick(); + expect(backNavigation.target()).toBeNull(); + + page.available.set(true); + TestBed.tick(); + expect(backNavigation.target()).not.toBeNull(); + + fixture.destroy(); + expect(backNavigation.target()).toBeNull(); + }); +}); diff --git a/libs/portal/shared/data-access/src/lib/register-workspace-back.ts b/libs/portal/shared/data-access/src/lib/register-workspace-back.ts new file mode 100644 index 000000000..18c68ea90 --- /dev/null +++ b/libs/portal/shared/data-access/src/lib/register-workspace-back.ts @@ -0,0 +1,39 @@ +import { effect, inject, Signal, signal } from '@angular/core'; +import { + WorkspaceBackPhoneSlot, + WorkspaceBackTarget, +} from '@iptvnator/portal/shared/util'; +import { WorkspaceBackNavigationService } from './workspace-back-navigation.service'; + +export interface WorkspaceBackRegistration { + /** Registers only while this returns true; always when omitted. */ + readonly available?: () => boolean; + /** Accessible name and tooltip; the generic "Back" when omitted. */ + readonly label?: Signal; + /** True while Escape on the page runs the same action; false if omitted. */ + readonly escapeShortcut?: Signal; + readonly phoneDrawerToggle?: WorkspaceBackPhoneSlot; + run(): void; +} + +/** + * Offers a page's Back in the workspace header instead of an arrow of its + * own, for as long as the calling component lives and `available` holds. + * Must be called in an injection context (a field initializer or the + * constructor). + */ +export function registerWorkspaceBack( + registration: WorkspaceBackRegistration +): void { + const backNavigation = inject(WorkspaceBackNavigationService); + const target: WorkspaceBackTarget = { + label: registration.label ?? signal(null), + escapeShortcut: registration.escapeShortcut ?? signal(false), + phoneDrawerToggle: registration.phoneDrawerToggle, + run: () => registration.run(), + }; + effect((onCleanup) => { + if (registration.available && !registration.available()) return; + onCleanup(backNavigation.register(target)); + }); +} diff --git a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts index a229d8e42..168642daf 100644 --- a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts +++ b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.spec.ts @@ -1,11 +1,62 @@ +import { Location } from '@angular/common'; import { signal } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util'; -import { WorkspaceBackNavigationService } from './workspace-back-navigation.service'; +import { + WORKSPACE_HISTORY_NAVIGATION, + WorkspaceBackNavigationService, + WorkspaceHistoryNavigation, +} from './workspace-back-navigation.service'; + +/** Session history as the Navigation API reports it. */ +class FakeHistory extends EventTarget { + private list: { index: number; sameDocument: boolean }[] = []; + private current = -1; + + constructor(sameDocument: boolean[] = [true]) { + super(); + sameDocument.forEach((same) => this.push(same, false)); + } + + get currentEntry() { + return this.list[this.current] ?? null; + } + + entries() { + return this.list; + } + + /** A router push; earlier documents' entries are not same-document. */ + push(sameDocument = true, notify = true): void { + this.list = this.list.slice(0, this.current + 1); + this.list.push({ index: this.list.length, sameDocument }); + this.current = this.list.length - 1; + if (notify) this.dispatchEvent(new Event('currententrychange')); + } + + traverseTo(index: number): void { + this.current = index; + this.dispatchEvent(new Event('currententrychange')); + } +} describe('WorkspaceBackNavigationService', () => { - function createService(): WorkspaceBackNavigationService { + const back = jest.fn(); + + function createService( + history: FakeHistory | null = null + ): WorkspaceBackNavigationService { + back.mockReset(); TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + { provide: Location, useValue: { back } }, + { + provide: WORKSPACE_HISTORY_NAVIGATION, + useValue: history as unknown as WorkspaceHistoryNavigation, + }, + ], + }); return TestBed.inject(WorkspaceBackNavigationService); } @@ -74,4 +125,81 @@ describe('WorkspaceBackNavigationService', () => { expect(service.target()).toBe(second); }); + + describe('history fallback', () => { + it('shows nothing on the first page of the session', () => { + const service = createService(new FakeHistory()); + + expect(service.target()).toBeNull(); + expect(service.goBack()).toBe(false); + }); + + it('goes back in history once the router pushed a page', () => { + const history = new FakeHistory(); + const service = createService(history); + + history.push(); + const target = service.target(); + + expect(target?.label()).toBeNull(); + // No page handles Escape, and a list keeps its phone drawer. + expect(target?.escapeShortcut()).toBe(false); + expect(target?.phoneDrawerToggle).toBe('yield'); + expect(service.goBack()).toBe(true); + expect(back).toHaveBeenCalledTimes(1); + }); + + it('disappears when Back returns to the first page and returns on Forward', () => { + const history = new FakeHistory(); + const service = createService(history); + history.push(); + + history.traverseTo(0); + expect(service.target()).toBeNull(); + + history.traverseTo(1); + expect(service.target()).not.toBeNull(); + }); + + it('never leads out of the app or across a reload', () => { + // An entry from another page of the origin, or from this app's + // document before a reload, belongs to a different document. + const service = createService(new FakeHistory([false, true])); + + expect(service.target()).toBeNull(); + }); + + it('yields to a page that registers Back and returns after it goes', () => { + const history = new FakeHistory(); + const service = createService(history); + history.push(); + const fallback = service.target(); + const page = createTarget(); + + const release = service.register(page); + expect(service.target()).toBe(page); + + release(); + expect(service.target()).toBe(fallback); + }); + + it('is absent without the Navigation API', () => { + const service = createService(null); + + expect(service.target()).toBeNull(); + }); + + it('stops listening when the injector is destroyed', () => { + const history = new FakeHistory(); + const remove = jest.spyOn(history, 'removeEventListener'); + createService(history); + + TestBed.resetTestingModule(); + + expect(remove).toHaveBeenCalledWith( + 'currententrychange', + expect.any(Function) + ); + }); + }); }); diff --git a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts index ec983282a..fd79fd8da 100644 --- a/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts +++ b/libs/portal/shared/data-access/src/lib/workspace-back-navigation.service.ts @@ -1,20 +1,91 @@ -import { computed, Injectable, signal } from '@angular/core'; +import { DOCUMENT, Location } from '@angular/common'; +import { + computed, + DestroyRef, + inject, + Injectable, + InjectionToken, + signal, +} from '@angular/core'; import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util'; +/** The parts of the browser's Navigation API the history fallback reads. */ +export type WorkspaceHistoryNavigation = Pick< + Navigation, + 'currentEntry' | 'entries' | 'addEventListener' | 'removeEventListener' +>; + +/** + * The browser's Navigation API; null where it is missing (older Safari and + * Firefox, jsdom), which leaves the header without the history fallback. + */ +export const WORKSPACE_HISTORY_NAVIGATION = + new InjectionToken( + 'WORKSPACE_HISTORY_NAVIGATION', + { + providedIn: 'root', + factory: () => inject(DOCUMENT).defaultView?.navigation ?? null, + } + ); + +/** + * True when the previous history entry belongs to this document, i.e. the + * router pushed it in this app session. Entries from before a reload or from + * another page of the origin are excluded, so the fallback never leaves the + * app or reloads it. + */ +function hasInAppPreviousEntry(history: WorkspaceHistoryNavigation): boolean { + const index = history.currentEntry?.index ?? -1; + return index > 0 && history.entries()[index - 1]?.sameDocument === true; +} + /** * Owns the header's Back slot. Pages register while they offer Back; the most * recent registration wins, so a page opened above another one takes the slot - * and hands it back when it goes away. + * and hands it back when it goes away. Without a registration the slot falls + * back to browser history while an in-app previous entry exists, and is empty + * otherwise (never a disabled arrow). */ @Injectable({ providedIn: 'root' }) export class WorkspaceBackNavigationService { + private readonly location = inject(Location); private readonly targets = signal([]); + private readonly canGoBackInApp = signal(false); + + /** + * Generic Back to the previous page. It advertises no Escape (no page + * handles one) and yields to the phone drawer toggle, so the categories + * of a list reached by navigation stay reachable. + */ + private readonly historyTarget: WorkspaceBackTarget = { + label: signal(null), + escapeShortcut: signal(false), + phoneDrawerToggle: 'yield', + run: () => this.location.back(), + }; readonly target = computed(() => { const targets = this.targets(); - return targets[targets.length - 1] ?? null; + return ( + targets[targets.length - 1] ?? + (this.canGoBackInApp() ? this.historyTarget : null) + ); }); + constructor() { + const history = inject(WORKSPACE_HISTORY_NAVIGATION); + if (!history) return; + // Fires for router pushes and replacements and for traversals, + // including a guard-cancelled Back that the router rewrites. + const sync = () => + this.canGoBackInApp.set(hasInAppPreviousEntry(history)); + sync(); + history.addEventListener('currententrychange', sync); + inject(DestroyRef).onDestroy(() => + history.removeEventListener('currententrychange', sync) + ); + } + /** * Returns the release function. It removes only this target: when one * page replaces another (a loading shell by the loaded one), creation and @@ -31,7 +102,7 @@ export class WorkspaceBackNavigationService { ); } - /** Runs the current target; false when no page offers Back. */ + /** Runs the current target; false when the header shows no Back. */ goBack(): boolean { const target = this.target(); if (!target) return false; diff --git a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html index 2fa1ce742..ae9eaf798 100644 --- a/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html +++ b/libs/portal/shared/ui/src/lib/components/search-layout/search-layout.component.html @@ -7,19 +7,7 @@