mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-13 04:26:16 -08:00
feat(workspace): move page Back buttons into the header and add a history fallback (#1814)
This commit is contained in:
1 parent
6f247fb538
commit
84aef83a6c
40 files changed
+902
-216
No files matched your search
+8
-18
@@ -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));
|
||||
}
|
||||
}
|
||||
+25
@@ -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();
|
||||
|
||||
|
||||
+12
-15
@@ -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();
|
||||
}
|
||||
}
|
||||
+8
-5
@@ -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"
|
||||
|
||||
+6
@@ -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
|
||||
|
||||
+54
-2
@@ -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', () => {
|
||||
|
||||
+15
-1
@@ -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>();
|
||||
|
||||
Reference in new issue
Block a user