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

@@ -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.
@@ -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);
});
}
+16 -1
View File
@@ -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
+27 -20
View File
@@ -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
+68 -4
View File
@@ -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:
@@ -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';
@@ -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();
});
});
@@ -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<string | null>;
/** True while Escape on the page runs the same action; false if omitted. */
readonly escapeShortcut?: Signal<boolean>;
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));
});
}
@@ -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)
);
});
});
});
@@ -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<WorkspaceHistoryNavigation | null>(
'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<readonly WorkspaceBackTarget[]>([]);
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;
@@ -7,19 +7,7 @@
<div class="header sticky-header">
<div class="header-top">
<div class="header-left">
<div class="header-title-group">
@if (showBackButton()) {
<button
mat-icon-button
class="header-back"
[attr.aria-label]="'BACK' | translate"
(click)="onBackClick()"
>
<mat-icon>arrow_back</mat-icon>
</button>
}
<h2>{{ title() | translate }}</h2>
</div>
<h2>{{ title() | translate }}</h2>
<!-- Filters slot -->
<div class="type-filters">
@@ -52,18 +52,6 @@
flex: 1;
flex-wrap: wrap;
// Keep the back arrow and title tightly grouped, independent of the
// wide gap between the title group and the filters (RTL-safe)
.header-title-group {
display: flex;
align-items: center;
gap: 4px;
}
.header-back {
flex-shrink: 0;
}
h2 {
margin: 0;
white-space: nowrap;
@@ -2,6 +2,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing';
import { By } from '@angular/platform-browser';
import { TranslatePipe } from '@ngx-translate/core';
import { MockPipe } from 'ng-mocks';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { SearchLayoutComponent } from './search-layout.component';
describe('SearchLayoutComponent', () => {
@@ -58,24 +59,35 @@ describe('SearchLayoutComponent', () => {
});
}
it('shows the back button only when showBackButton is set and emits backClick', () => {
fixture.detectChanges();
expect(
fixture.debugElement.query(By.css('.header-back'))
).toBeNull();
fixture.componentRef.setInput('showBackButton', true);
fixture.detectChanges();
const backButton = fixture.debugElement.query(By.css('.header-back'));
expect(backButton).not.toBeNull();
it('offers the header Back only while backAvailable is set and the results show', () => {
const backNavigation = TestBed.inject(WorkspaceBackNavigationService);
const emitted: unknown[] = [];
fixture.componentInstance.backClick.subscribe((value) =>
emitted.push(value)
);
(backButton.nativeElement as HTMLButtonElement).click();
fixture.detectChanges();
expect(backNavigation.target()).toBeNull();
fixture.componentRef.setInput('backAvailable', true);
fixture.detectChanges();
// The page carries no arrow of its own.
expect(
fixture.debugElement.query(By.css('button[aria-label="BACK"]'))
).toBeNull();
expect(backNavigation.goBack()).toBe(true);
expect(emitted).toHaveLength(1);
// An inline detail replaces the results and registers its own Back.
fixture.componentRef.setInput('showDetails', true);
fixture.detectChanges();
expect(backNavigation.target()).toBeNull();
fixture.componentRef.setInput('showDetails', false);
fixture.detectChanges();
expect(backNavigation.target()).not.toBeNull();
fixture.destroy();
expect(backNavigation.target()).toBeNull();
});
it('emits nearEnd when the results container is scrolled near the bottom', () => {
@@ -10,6 +10,7 @@ import { MatIconButton } from '@angular/material/button';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinner } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
import { InfiniteScrollDirective } from '../../directives/infinite-scroll.directive';
import { SearchFormComponent } from '../search-form/search-form.component';
@@ -48,8 +49,12 @@ export class SearchLayoutComponent {
/** Whether to show the close button (for dialog mode) */
readonly showCloseButton = input<boolean>(false);
/** Whether to show the back button (nested search reached via navigation) */
readonly showBackButton = input<boolean>(false);
/**
* Whether the page offers Back (a nested search reached via navigation).
* It is the workspace header's Back, registered while the results show;
* an inline detail above them brings its own.
*/
readonly backAvailable = input<boolean>(false);
/** Whether to show results count */
readonly showResultsCount = input<boolean>(true);
@@ -106,7 +111,7 @@ export class SearchLayoutComponent {
/** Emitted when close button is clicked */
readonly closeClick = output<void>();
/** Emitted when the back button is clicked */
/** Emitted by the header's Back while `backAvailable` is set */
readonly backClick = output<void>();
/**
@@ -116,6 +121,13 @@ export class SearchLayoutComponent {
*/
readonly nearEnd = output<void>();
constructor() {
registerWorkspaceBack({
available: () => this.backAvailable() && !this.showDetails(),
run: () => this.backClick.emit(),
});
}
/** Focus the search input */
focusSearchInput(): void {
this.searchFormComponent()?.focusSearchInput();
@@ -142,10 +154,6 @@ export class SearchLayoutComponent {
this.closeClick.emit();
}
onBackClick(): void {
this.backClick.emit();
}
/** Check if we should show the "no results" state */
get showNoResults(): boolean {
return (
@@ -1,10 +1,25 @@
import { Signal } from '@angular/core';
/**
* How Back shares the phone header (≤640 px) with the context drawer toggle
* on routes that have one.
*
* - `replace` (default): Back takes the toggle's slot. The drawer belongs to
* the list Back returns to (detail pages), which shows the toggle again.
* - `beside`: both stay. The drawer holds the page's own navigation
* (settings sections), so hiding its toggle would strand it.
* - `yield`: Back hides while the toggle shows. Used by the history
* fallback, so a category list never loses its drawer to it.
*/
export type WorkspaceBackPhoneSlot = 'replace' | 'beside' | 'yield';
/** A page's Back action, rendered in the workspace header's leading slot. */
export interface WorkspaceBackTarget {
/** Accessible name and tooltip; null falls back to the generic "Back". */
readonly label: Signal<string | null>;
/** True while Escape on the page runs the same action. */
readonly escapeShortcut: Signal<boolean>;
/** Phone-width placement beside the drawer toggle; `replace` if unset. */
readonly phoneDrawerToggle?: WorkspaceBackPhoneSlot;
run(): void;
}
@@ -4,7 +4,7 @@
[isLoading]="isInitialSearchLoading()"
[showResultsCount]="true"
[showSearchInput]="!isWorkspaceLayout"
[showBackButton]="isWorkspaceLayout"
[backAvailable]="isWorkspaceLayout"
[showDetails]="showingDetails"
[nearEndHasMore]="searchHasMore()"
[nearEndAppending]="isAppendingSearchResults()"
@@ -3,7 +3,7 @@
[resultsCount]="xtreamStore.searchResults().length"
[isLoading]="xtreamStore.isSearching()"
[showCloseButton]="false"
[showBackButton]="showBackButton"
[backAvailable]="backAvailable"
[showResultsCount]="!isGlobalSearch || !groupByPlaylist()"
[showSearchInput]="showInlineSearchInput"
[minSearchLength]="minSearchLength"
@@ -575,11 +575,12 @@ export class SearchResultsComponent implements AfterViewInit {
/**
* The in-portal search is a nested view (reached from the toolbar
* search box or an actor page); the global search is a top-level
* sidebar destination, and the dialog has its own close button — a
* back arrow there would navigate the page behind the open dialog.
* search box or an actor page), so it offers the header's Back; the
* global search is a top-level sidebar destination, and the dialog has
* its own close button — a Back there would navigate the page behind
* the open dialog.
*/
get showBackButton(): boolean {
get backAvailable(): boolean {
return (
this.isWorkspaceLayout && !this.isGlobalSearch && !this.dialogRef
);
@@ -12,8 +12,7 @@ import {
output,
ChangeDetectionStrategy,
} from '@angular/core';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
import { ContentHeroComponent } from '../content-hero/content-hero.component';
import { ContentAboutComponent } from './content-about.component';
import {
@@ -61,7 +60,6 @@ import {
export class PortalDetailShellComponent {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
private readonly backNavigation = inject(WorkspaceBackNavigationService);
readonly title = input<string>();
/** "Movie · playlist name" eyebrow above the hero title. */
@@ -96,13 +94,13 @@ export class PortalDetailShellComponent {
readonly isWatch = computed(() => this.playbackActive());
private readonly backTarget: WorkspaceBackTarget = {
label: computed(() => this.backLabel() || null),
escapeShortcut: computed(() => !this.isWatch()),
run: () => this.backClicked.emit(),
};
constructor() {
registerWorkspaceBack({
available: this.backAvailable,
label: computed(() => this.backLabel() || null),
escapeShortcut: computed(() => !this.isWatch()),
run: () => this.backClicked.emit(),
});
afterNextRender(() => {
const element = this.host.nativeElement;
const active = element.ownerDocument.activeElement;
@@ -115,10 +113,6 @@ export class PortalDetailShellComponent {
element.focus({ preventScroll: true });
}
});
effect((onCleanup) => {
if (!this.backAvailable()) return;
onCleanup(this.backNavigation.register(this.backTarget));
});
let wasWatch = false;
effect(() => {
const watch = this.isWatch();
@@ -1,13 +1,4 @@
<div class="actor-view">
<button
class="actor-view__back"
type="button"
(click)="backClicked.emit()"
[attr.aria-label]="'BACK' | translate"
>
<mat-icon>arrow_back</mat-icon>
</button>
@if (isLoading()) {
<div class="actor-view__loading">
<mat-spinner diameter="48" />
@@ -32,24 +32,6 @@
padding: 24px 32px 48px;
position: relative;
&__back {
align-items: center;
background: var(--chip-bg);
border: none;
border-radius: 50%;
color: var(--text-primary);
cursor: pointer;
display: inline-flex;
height: 40px;
justify-content: center;
margin-bottom: 16px;
width: 40px;
&:hover {
background: var(--chip-border);
}
}
&__loading {
align-items: center;
display: flex;
@@ -0,0 +1,28 @@
import { TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { ActorViewComponent } from './actor-view.component';
describe('ActorViewComponent', () => {
it('offers its Back in the workspace header instead of its own arrow', () => {
TestBed.configureTestingModule({
imports: [ActorViewComponent, TranslateModule.forRoot()],
});
const fixture = TestBed.createComponent(ActorViewComponent);
const backClicked = jest.fn();
fixture.componentInstance.backClicked.subscribe(backClicked);
fixture.detectChanges();
const backNavigation = TestBed.inject(WorkspaceBackNavigationService);
// Available while the profile loads, too.
expect(
(fixture.nativeElement as HTMLElement).querySelector('button')
).toBeNull();
expect(backNavigation.target()?.escapeShortcut()).toBe(false);
backNavigation.goBack();
expect(backClicked).toHaveBeenCalledTimes(1);
fixture.destroy();
expect(backNavigation.target()).toBeNull();
});
});
@@ -4,9 +4,9 @@ import {
input,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
import {
ActorFilmographyCredit,
ActorProfile,
@@ -32,7 +32,6 @@ export type ActorViewScope = TitleResultsScope;
@Component({
selector: 'app-actor-view',
imports: [
MatIcon,
MatProgressSpinnerModule,
TranslatePipe,
TitleResultsComponent,
@@ -54,6 +53,11 @@ export class ActorViewComponent {
readonly scope = input<ActorViewScope>('portal');
readonly itemClicked = output<ActorViewItem>();
/** The workspace header's Back on this page. */
readonly backClicked = output<void>();
readonly scopeChanged = output<ActorViewScope>();
constructor() {
registerWorkspaceBack({ run: () => this.backClicked.emit() });
}
}
@@ -1,13 +1,4 @@
<div class="discover-view">
<button
class="discover-view__back"
type="button"
(click)="backClicked.emit()"
[attr.aria-label]="'BACK' | translate"
>
<mat-icon>arrow_back</mat-icon>
</button>
<header class="discover-view__header">
<h1>{{ 'XTREAM.DISCOVER_TITLE' | translate }}</h1>
<div class="discover-view__facets">
@@ -30,24 +30,6 @@
padding: 24px 32px 48px;
position: relative;
&__back {
align-items: center;
background: var(--chip-bg);
border: none;
border-radius: 50%;
color: var(--text-primary);
cursor: pointer;
display: inline-flex;
height: 40px;
justify-content: center;
margin-bottom: 16px;
width: 40px;
&:hover {
background: var(--chip-border);
}
}
&__loading {
align-items: center;
display: flex;
@@ -0,0 +1,37 @@
import { TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { DiscoverViewComponent } from './discover-view.component';
describe('DiscoverViewComponent', () => {
it('offers its Back in the workspace header instead of its own arrow', () => {
TestBed.configureTestingModule({
imports: [DiscoverViewComponent, TranslateModule.forRoot()],
});
const fixture = TestBed.createComponent(DiscoverViewComponent);
fixture.componentRef.setInput('facets', {
type: 'movie',
genreId: 18,
genreLabel: 'Drama',
year: null,
countryCode: null,
countryLabel: null,
});
const backClicked = jest.fn();
fixture.componentInstance.backClicked.subscribe(backClicked);
fixture.detectChanges();
const backNavigation = TestBed.inject(WorkspaceBackNavigationService);
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'.discover-view > button'
)
).toBeNull();
expect(backNavigation.target()?.escapeShortcut()).toBe(false);
backNavigation.goBack();
expect(backClicked).toHaveBeenCalledTimes(1);
fixture.destroy();
expect(backNavigation.target()).toBeNull();
});
});
@@ -5,9 +5,9 @@ import {
input,
output,
} from '@angular/core';
import { MatIcon } from '@angular/material/icon';
import { MatProgressSpinnerModule } from '@angular/material/progress-spinner';
import { TranslatePipe } from '@ngx-translate/core';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
import {
TitleResultItem,
TitleResultsComponent,
@@ -23,7 +23,6 @@ import { DiscoverRouteParams } from './discover-params';
@Component({
selector: 'app-discover-view',
imports: [
MatIcon,
MatProgressSpinnerModule,
TranslatePipe,
TitleResultsComponent,
@@ -45,6 +44,7 @@ export class DiscoverViewComponent<T extends TitleResultItem> {
readonly scope = input<TitleResultsScope>('portal');
readonly itemClicked = output<T>();
/** The workspace header's Back on this page. */
readonly backClicked = output<void>();
readonly scopeChanged = output<TitleResultsScope>();
@@ -63,4 +63,8 @@ export class DiscoverViewComponent<T extends TitleResultItem> {
facets.countryLabel ?? facets.countryCode,
].filter((label): label is string => label !== null);
});
constructor() {
registerWorkspaceBack({ run: () => this.backClicked.emit() });
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
"types": ["jest", "node"],
"moduleResolution": "bundler"
},
"files": ["src/test-setup.ts"],
"files": ["src/test-setup.ts", "../../../global.d.ts"],
"include": [
"jest.config.ts",
"src/**/*.test.ts",
@@ -32,17 +32,14 @@
}
// `height: 100%` here does not resolve against a stretched flex item, so the
// list kept its full content height and painted over the footer whenever the
// list kept its full content height and overflowed the panel whenever the
// panel was shorter than its sections (a phone, or a short desktop window).
.settings-sections-list {
flex: 1;
min-height: 0;
}
.settings-panel-footer {
flex-shrink: 0;
padding: 12px 8px calc(20px + env(safe-area-inset-bottom, 0px));
border-top: 1px solid var(--app-separator);
// The list now reaches the panel's bottom edge (Back moved to the
// header), so it clears the home indicator itself.
padding-bottom: calc(12px + env(safe-area-inset-bottom, 0px));
}
// Sections without settings-search matches stay reachable but recede.
@@ -50,16 +47,13 @@
opacity: 0.55;
}
.settings-section-item:hover:not(.active):not(.selected),
.settings-back-button:hover:not(.active):not(.selected) {
.settings-section-item:hover:not(.active):not(.selected) {
background: var(--app-selection-surface);
}
// On a landscape phone the stacked panel gets ~108-148px, and this panel's
// fixed chrome (title + footer) alone is about that tall — the section list
// was collapsing to nothing behind an overlapping footer. The rail already
// labels the page, so the caption is what gives way, and the footer sheds
// the padding it carries for taller screens.
// On a landscape phone the panel is only ~108-148px tall, and its caption
// would take a third of that from the section list. The rail already labels
// the page, so the caption is what gives way.
@media (max-width: 640px) and (max-height: 599px) {
.panel-title {
display: none;
@@ -68,8 +62,4 @@
.settings-panel-body {
padding-top: 8px;
}
.settings-panel-footer {
padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px));
}
}
@@ -1,7 +1,9 @@
import { Location } from '@angular/common';
import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
import { WorkspaceSettingsContextPanelComponent } from './workspace-settings-context-panel.component';
@@ -46,6 +48,29 @@ describe('WorkspaceSettingsContextPanelComponent', () => {
return { ctx, fixture, link };
}
it('offers Back in the header, beside the phone drawer toggle', () => {
const { fixture } = setup();
const back = jest
.spyOn(TestBed.inject(Location), 'back')
.mockImplementation(() => undefined);
const backNavigation = TestBed.inject(WorkspaceBackNavigationService);
// The panel carries no footer arrow of its own.
expect(
(fixture.nativeElement as HTMLElement).querySelector('button')
).toBeNull();
const target = backNavigation.target();
// The drawer holds the sections, so its toggle must stay reachable.
expect(target?.phoneDrawerToggle).toBe('beside');
expect(target?.label()).toBeNull();
backNavigation.goBack();
expect(back).toHaveBeenCalledTimes(1);
fixture.destroy();
expect(backNavigation.target()).toBeNull();
});
it('shows no counts while settings search is idle', () => {
const { link } = setup();
@@ -3,6 +3,7 @@ import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { RouterLink, RouterLinkActive } from '@angular/router';
import { TranslateModule } from '@ngx-translate/core';
import { registerWorkspaceBack } from '@iptvnator/portal/shared/data-access';
import { WorkspaceShellContextDrawerService } from '@iptvnator/workspace/shell/util';
import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings-context';
@@ -18,7 +19,7 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings
@for (section of ctx.sections(); track section.id) {
<!-- replaceUrl keeps a single settings entry in the
browser history: switching sections must not turn
"Back" (footer or browser) into a walk through every
"Back" (header or browser) into a walk through every
visited section page before finally leaving. -->
<a
class="nav-item settings-section-item"
@@ -51,16 +52,6 @@ import { SettingsContextService } from '@iptvnator/workspace/shell/util/settings
}
</div>
</div>
<div class="settings-panel-footer">
<button
type="button"
class="nav-item settings-back-button"
(click)="onBack()"
>
<mat-icon>arrow_back</mat-icon>
<span>{{ 'SETTINGS.BACK_TO_HOME' | translate }}</span>
</button>
</div>
`,
})
export class WorkspaceSettingsContextPanelComponent {
@@ -77,11 +68,17 @@ export class WorkspaceSettingsContextPanelComponent {
}
);
constructor() {
// The panel exists exactly while the settings route shows, so it
// offers the page's Back in the header. On a phone the toggle for
// this drawer stays beside it: the drawer holds the sections.
registerWorkspaceBack({
phoneDrawerToggle: 'beside',
run: () => this.location.back(),
});
}
onSectionClicked() {
this.contextDrawer?.close();
}
onBack() {
this.location.back();
}
}
@@ -1,12 +1,14 @@
<header class="workspace-header" [class.is-settings-route]="isSettingsRoute()">
<!-- The page's Back, at the leading edge where desktop apps and Material's
top app bar keep navigation (beside the macOS traffic lights). The
<!-- The page's Back, or browser history while an in-app previous page
exists, at the leading edge where desktop apps and Material's top
app bar keep navigation (beside the macOS traffic lights). The
header never scrolls, so the control stays put over long pages. -->
@if (backTarget(); as back) {
@let backLabel = back.label() || ('BACK' | translate);
<button
type="button"
class="header-back"
[class.header-back--yields]="backYieldsToDrawerToggle()"
mat-icon-button
data-test-id="workspace-header-back"
(click)="backRequested.emit()"
@@ -23,9 +25,10 @@
<!-- Phone-only (≤640px, CSS-hidden above): on phones the context panel
is an off-canvas drawer, and this button is the only way to open
it. A page with Back takes its slot (one navigation icon, as in
Material's top app bar); the list Back returns to shows it again. -->
@if (showContextDrawerToggle() && !backTarget()) {
it. A detail page's Back takes its slot (one navigation icon, as in
Material's top app bar); the list Back returns to shows it again.
Settings keeps both, and the history fallback yields to it. -->
@if (showDrawerToggle()) {
<button
#contextDrawerToggle
type="button"
@@ -351,6 +351,12 @@
flex-shrink: 0;
}
// The history fallback must not cost a list its only way into the
// drawer, and two navigation icons do not fit beside the switcher.
.header-back--yields {
display: none;
}
.playlist-switcher {
width: auto;
// Fixed at 140px this crowded the search field off a 320px header
@@ -15,7 +15,10 @@ import { MatTooltip } from '@angular/material/tooltip';
import { TranslateService } from '@ngx-translate/core';
import { of } from 'rxjs';
import { TranslatePipe } from '@ngx-translate/core';
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
import {
WorkspaceBackPhoneSlot,
WorkspaceBackTarget,
} from '@iptvnator/portal/shared/util';
import { WorkspaceShellHeaderComponent } from './workspace-shell-header.component';
@Component({
@@ -384,13 +387,20 @@ describe('WorkspaceShellHeaderComponent', () => {
'[data-test-id="workspace-header-back"]'
);
const drawerToggle = (): HTMLButtonElement | null =>
fixture.nativeElement.querySelector(
'[data-test-id="context-drawer-toggle"]'
);
function setBackTarget(
label: string | null,
escapeShortcut: boolean
escapeShortcut: boolean,
phoneDrawerToggle?: WorkspaceBackPhoneSlot
): WorkspaceBackTarget {
const target: WorkspaceBackTarget = {
label: signal(label),
escapeShortcut: signal(escapeShortcut),
phoneDrawerToggle,
run: jest.fn(),
};
fixture.componentRef.setInput('backTarget', target);
@@ -479,6 +489,48 @@ describe('WorkspaceShellHeaderComponent', () => {
)
).not.toBeNull();
});
it('keeps the drawer toggle after Back for a page whose sections live in the drawer', () => {
fixture.componentRef.setInput('showContextDrawerToggle', true);
setBackTarget(null, false, 'beside');
expect(drawerToggle()).not.toBeNull();
expect(backButton()?.nextElementSibling).toBe(drawerToggle());
expect(backButton()?.classList).not.toContain(
'header-back--yields'
);
});
it('lets the history fallback yield to the drawer toggle at phone width', () => {
fixture.componentRef.setInput('showContextDrawerToggle', true);
setBackTarget(null, false, 'yield');
expect(drawerToggle()).not.toBeNull();
// Hidden by the ≤640px rule; above it the toggle itself is hidden.
expect(backButton()?.classList).toContain('header-back--yields');
// Routes without a drawer keep the fallback at every width.
fixture.componentRef.setInput('showContextDrawerToggle', false);
fixture.detectChanges();
expect(backButton()?.classList).not.toContain(
'header-back--yields'
);
});
});
it('hides a yielding Back only in the phone layout', () => {
const styleSource = readFileSync(
join(__dirname, 'workspace-shell-header.component.scss'),
'utf8'
);
const phoneStart = styleSource.indexOf('@media (max-width: 640px)');
expect(styleSource.indexOf('.header-back--yields')).toBeGreaterThan(
phoneStart
);
expect(styleSource.slice(phoneStart)).toMatch(
/\.header-back--yields\s*\{\s*display:\s*none;/
);
});
it('uses the paired Material primary tokens for the download badge', () => {
@@ -49,7 +49,7 @@ export class WorkspaceShellHeaderComponent {
/Mac|iPhone|iPad|iPod/i.test(navigator.platform);
readonly commandShortcutLabel = this.isMac ? '⌘K' : 'Ctrl+K';
/** The current page's Back action; null when the page has none. */
/** The page's Back or the history fallback; null when there is neither. */
readonly backTarget = input<WorkspaceBackTarget | null>(null);
readonly playlistTitle = input('');
readonly playlistSubtitle = input('');
@@ -110,6 +110,20 @@ export class WorkspaceShellHeaderComponent {
'WORKSPACE.SHELL.CONTEXT_DRAWER_CATEGORIES_TOOLTIP'
);
private readonly backPhoneSlot = computed(() => {
const back = this.backTarget();
return back ? (back.phoneDrawerToggle ?? 'replace') : null;
});
/** The drawer toggle renders unless Back takes its phone slot. */
readonly showDrawerToggle = computed(
() =>
this.showContextDrawerToggle() && this.backPhoneSlot() !== 'replace'
);
/** At phone width, Back hides behind a shown drawer toggle. */
readonly backYieldsToDrawerToggle = computed(
() => this.showDrawerToggle() && this.backPhoneSlot() === 'yield'
);
readonly backRequested = output<void>();
readonly searchChanged = output<string>();
readonly searchSubmitted = output<string>();