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

@@ -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>();