fix(ui): keep detail back navigation available while scrolling (#1576)

This commit is contained in:
4gray authored and GitHub committed 2026-09-10 21:34:10 +02:00
1 parent e76447975b
commit fff022afe4
28 files changed
+679 -138

No files matched your search

@@ -1,14 +1,5 @@
<!-- Hero Section with Backdrop -->
<section class="hero">
<!-- Back Button -->
<button
class="hero__back-button"
type="button"
[attr.aria-label]="backLabel() || ('BACK' | translate)"
(click)="onBack()"
>
<mat-icon aria-hidden="true">arrow_back</mat-icon>
</button>
@if (errorMessage()) {
<div class="hero__content hero__error">
<mat-icon class="error-icon">cloud_off</mat-icon>
@@ -57,39 +57,6 @@
--surface-bg
); // Solid background prevents any bleed-through
// Floating back button over backdrop
&__back-button {
position: absolute;
top: 16px;
left: 16px;
z-index: 10;
app-region: no-drag;
-webkit-app-region: no-drag;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(8px);
border: none;
cursor: pointer;
transition: all 0.2s ease;
color: #fff;
&:hover {
background: rgba(0, 0, 0, 0.7);
transform: scale(1.05);
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
// Backdrop image layer - fixed at top, never repeats
&__backdrop {
position: absolute;
@@ -42,26 +42,6 @@ describe('ContentHeroComponent', () => {
expect(host.textContent).toContain('Plain description');
});
it('renders an accessible button with the translated fallback back label', () => {
fixture.detectChanges();
const back = (fixture.nativeElement as HTMLElement).querySelector(
'.hero__back-button'
) as HTMLButtonElement;
expect(back.type).toBe('button');
expect(back.getAttribute('aria-label')).toBe('Go back');
});
it('uses an explicit back label when supplied', () => {
fixture.componentRef.setInput('backLabel', 'Back to downloads');
fixture.detectChanges();
const back = (fixture.nativeElement as HTMLElement).querySelector(
'.hero__back-button'
) as HTMLButtonElement;
expect(back.getAttribute('aria-label')).toBe('Back to downloads');
});
it('resets a poster failure when the poster URL changes', () => {
fixture.componentRef.setInput('posterUrl', 'broken.jpg');
fixture.detectChanges();
@@ -5,7 +5,6 @@ import {
effect,
inject,
input,
output,
signal,
untracked,
viewChild,
@@ -37,9 +36,7 @@ export class ContentHeroComponent {
readonly backdropUrl = input<string>();
readonly isLoading = input(false);
readonly errorMessage = input<string>();
readonly backLabel = input<string>();
readonly backClicked = output<void>();
readonly posterError = signal(false);
private readonly failedBackdropUrl = signal<string | undefined>(undefined);
readonly backdropSourceUrl = computed(
@@ -123,10 +120,6 @@ export class ContentHeroComponent {
return `linear-gradient(135deg, hsl(${hue}, 50%, 15%) 0%, hsl(${h2}, 80%, 5%) 100%)`;
});
onBack(): void {
this.backClicked.emit();
}
toggleDescription(): void {
this.isDescriptionExpanded.update((v) => !v);
}
@@ -1,3 +1,23 @@
@if (backAvailable() || isWatch()) {
@let label =
isWatch()
? ('PORTALS.CLOSE_PLAYER' | translate)
: backLabel() || ('BACK' | translate);
<div class="shell__navigation">
<button
#backButton
class="shell__back-button"
type="button"
[attr.aria-label]="label"
aria-keyshortcuts="Escape"
[title]="label + ' (Esc)'"
(click)="onBack()"
>
<mat-icon aria-hidden="true">arrow_back</mat-icon>
</button>
</div>
}
<!-- Hero (browse state) — collapses when playback is active -->
<div class="shell__hero" [class.shell__hero--collapsed]="isWatch()">
<div class="shell__hero-inner">
@@ -8,8 +28,6 @@
[backdropUrl]="backdropUrl()"
[isLoading]="isLoading()"
[errorMessage]="errorMessage()"
[backLabel]="backLabel()"
(backClicked)="backClicked.emit()"
>
<ng-container hero-tags>
@if (tagsTemplate(); as tags) {
@@ -40,6 +40,48 @@
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.5);
}
.shell__navigation {
// Direct child of the scroll owner: sticky lasts through the entire page.
position: sticky;
top: 16px;
height: 0;
z-index: 10;
pointer-events: none;
}
.shell__back-button {
position: absolute;
left: 16px;
pointer-events: auto;
app-region: no-drag;
-webkit-app-region: no-drag;
display: flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 50%;
background: var(--app-widget-bg, var(--surface-bg));
color: var(--app-on-surface, var(--text-primary));
border: 1px solid var(--app-separator, var(--tag-border));
cursor: pointer;
&:hover {
background: var(--app-card-hover-bg, var(--surface-bg));
}
&:focus-visible {
outline: 2px solid var(--app-selection-color);
outline-offset: 2px;
}
mat-icon {
font-size: 24px;
width: 24px;
height: 24px;
}
}
// ============================================================================
// Hero collapse (browse ↔ watch morph, ~300ms, no height measuring)
// ============================================================================
@@ -1,6 +1,6 @@
import { Component, signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import {
DetailActionsTemplateDirective,
DetailMetaTemplateDirective,
@@ -22,8 +22,10 @@ import { PortalDetailShellComponent } from './portal-detail-shell.component';
[description]="'Show description'"
[posterUrl]="'poster.jpg'"
[backLabel]="'Return to downloads'"
[backAvailable]="backAvailable()"
[playbackActive]="playbackActive()"
(closePlayerRequested)="closeRequests = closeRequests + 1"
(backClicked)="backRequests = backRequests + 1"
(closePlayerRequested)="closePlayer()"
>
<ng-template appDetailTags>
<span class="details__tag">2026</span>
@@ -44,7 +46,14 @@ import { PortalDetailShellComponent } from './portal-detail-shell.component';
})
class HostComponent {
readonly playbackActive = signal(false);
readonly backAvailable = signal(true);
closeRequests = 0;
backRequests = 0;
closePlayer(): void {
this.closeRequests++;
this.playbackActive.set(false);
}
}
describe('PortalDetailShellComponent', () => {
@@ -54,10 +63,22 @@ describe('PortalDetailShellComponent', () => {
const query = (selector: string): HTMLElement | null =>
(fixture.nativeElement as HTMLElement).querySelector(selector);
const requiredQuery = (selector: string): HTMLElement => {
const element = query(selector);
if (!element) throw new Error(`Missing element: ${selector}`);
return element;
};
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [HostComponent, TranslateModule.forRoot()],
}).compileComponents();
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', {
BACK: 'Go back',
PORTALS: { CLOSE_PLAYER: 'Close player' },
});
translate.use('en');
fixture = TestBed.createComponent(HostComponent);
host = fixture.componentInstance;
@@ -66,11 +87,11 @@ describe('PortalDetailShellComponent', () => {
it('makes the scroll owner a named keyboard region and focuses it once', async () => {
await fixture.whenStable();
const shell = query('app-portal-detail-shell')!;
const shell = requiredQuery('app-portal-detail-shell');
expect(shell.tabIndex).toBe(0);
expect(shell.getAttribute('aria-label')).toBe('Show Title');
expect(document.activeElement).toBe(shell);
const play = query('.play-btn')!;
const play = requiredQuery('.play-btn');
play.focus();
host.playbackActive.set(true);
fixture.detectChanges();
@@ -79,7 +100,7 @@ describe('PortalDetailShellComponent', () => {
});
it('keeps native scrolling on the shell without forwarding keys to the player', () => {
const shell = query('app-portal-detail-shell')!;
const shell = requiredQuery('app-portal-detail-shell');
const globalKey = jest.fn();
document.addEventListener('keydown', globalKey);
try {
@@ -91,7 +112,7 @@ describe('PortalDetailShellComponent', () => {
shell.dispatchEvent(event);
expect(event.defaultPrevented).toBe(false);
expect(globalKey).not.toHaveBeenCalled();
query('.play-btn')!.dispatchEvent(
requiredQuery('.play-btn').dispatchEvent(
new KeyboardEvent('keydown', { key: ' ', bubbles: true })
);
expect(globalKey).toHaveBeenCalledTimes(1);
@@ -109,11 +130,34 @@ describe('PortalDetailShellComponent', () => {
expect(query('.details__meta .details__meta-item')).toBeTruthy();
expect(query('.action-buttons .play-btn')).toBeTruthy();
expect(query('app-content-about')).toBeNull();
expect(query('.hero__back-button')?.getAttribute('aria-label')).toBe(
expect(query('.shell__back-button')?.getAttribute('aria-label')).toBe(
'Return to downloads'
);
});
it.each([{ isLoading: true }, { errorMessage: 'Unavailable' }])(
'keeps the translated fallback Back available in loading/error states',
(state) => {
fixture.destroy();
const shellFixture = TestBed.createComponent(
PortalDetailShellComponent
);
for (const [key, value] of Object.entries(state))
shellFixture.componentRef.setInput(key, value);
shellFixture.detectChanges();
const element = shellFixture.nativeElement as HTMLElement;
const button = element.querySelector<HTMLButtonElement>(
'.shell__back-button'
);
expect(button?.type).toBe('button');
expect(button?.getAttribute('aria-label')).toBe('Go back');
const back = jest.fn();
shellFixture.componentInstance.backClicked.subscribe(back);
button?.click();
expect(back).toHaveBeenCalledTimes(1);
}
);
it('collapses hero and shows About with re-stamped templates in watch state', () => {
host.playbackActive.set(true);
fixture.detectChanges();
@@ -143,19 +187,80 @@ describe('PortalDetailShellComponent', () => {
expect(query('.fake-player')).toBeNull();
});
it('emits closePlayerRequested on Escape only during playback', () => {
it('unwinds one level per Escape and consumes handled events', () => {
const escape = () =>
document.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })
requiredQuery('app-portal-detail-shell').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
})
);
escape();
expect(host.closeRequests).toBe(0);
host.playbackActive.set(true);
fixture.detectChanges();
escape();
expect(escape()).toBe(false);
expect(host.closeRequests).toBe(1);
expect(host.backRequests).toBe(0);
fixture.detectChanges();
expect(escape()).toBe(false);
expect(host.backRequests).toBe(1);
});
it.each([false, true])(
'handles descendant Escape before body tooltip dispatch, once (watch=%s)',
(watch) => {
host.playbackActive.set(watch);
fixture.detectChanges();
const consumeTooltipKey = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault();
event.stopPropagation();
}
};
document.body.addEventListener('keydown', consumeTooltipKey);
try {
requiredQuery(
watch ? '.fake-player' : '.play-btn'
).dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
})
);
expect(host.closeRequests).toBe(watch ? 1 : 0);
expect(host.backRequests).toBe(watch ? 0 : 1);
} finally {
document.body.removeEventListener('keydown', consumeTooltipKey);
}
}
);
it('retains the priority of a descendant that already handled Escape', () => {
const play = requiredQuery('.play-btn');
play.addEventListener('keydown', (event) => event.preventDefault());
play.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
})
);
expect(host.backRequests).toBe(0);
});
it('closes playback once when Escape comes from outside the shell', () => {
host.playbackActive.set(true);
fixture.detectChanges();
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
})
);
expect(host.closeRequests).toBe(1);
expect(host.backRequests).toBe(0);
});
it('ignores Escape when the event was already handled', () => {
@@ -171,4 +276,147 @@ describe('PortalDetailShellComponent', () => {
document.dispatchEvent(event);
expect(host.closeRequests).toBe(0);
});
it('keeps one labelled back control outside the collapsing hero', async () => {
const back = requiredQuery('.shell__back-button');
expect(back.closest('app-content-hero')).toBeNull();
back.click();
expect(host.backRequests).toBe(1);
host.playbackActive.set(true);
fixture.detectChanges();
expect(query('.shell__back-button')).toBe(back);
expect(back.getAttribute('aria-label')).toBe('Close player');
expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape');
requiredQuery('.fake-player').tabIndex = 0;
requiredQuery('.fake-player').focus();
back.click();
fixture.detectChanges();
await fixture.whenStable();
expect(host.closeRequests).toBe(1);
expect(host.backRequests).toBe(1);
expect(document.activeElement).toBe(back);
});
it('has no dead-end browse action for a host without back navigation', () => {
host.backAvailable.set(false);
fixture.detectChanges();
expect(query('.shell__back-button')).toBeNull();
requiredQuery('app-portal-detail-shell').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
})
);
expect(host.backRequests).toBe(0);
host.playbackActive.set(true);
fixture.detectChanges();
requiredQuery('.shell__back-button').click();
expect(host.closeRequests).toBe(1);
});
it.each([
'input',
'textarea',
'select',
'div[contenteditable]',
'div[role=dialog]',
'div[role=menu]',
])('leaves Escape to %s', (selector) => {
const shell = requiredQuery('app-portal-detail-shell');
const control = document.createElement(selector.split('[')[0]);
if (selector.includes('contenteditable'))
control.setAttribute('contenteditable', 'true');
if (selector.includes('role='))
control.setAttribute(
'role',
selector.includes('dialog') ? 'dialog' : 'menu'
);
shell.append(control);
control.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
})
);
expect(host.backRequests).toBe(0);
control.remove();
});
it('ignores held Escape and events outside this shell', () => {
const event = () =>
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
repeat: true,
});
requiredQuery('app-portal-detail-shell').dispatchEvent(event());
document.body.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
})
);
expect(host.backRequests).toBe(0);
});
it.each([false, true])(
'ignores an inert shell and browser fullscreen (watch=%s)',
(watch) => {
host.playbackActive.set(watch);
fixture.detectChanges();
const shell = requiredQuery('app-portal-detail-shell');
const escape = () =>
shell.dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
})
);
shell.setAttribute('inert', '');
escape();
shell.removeAttribute('inert');
const descriptor = Object.getOwnPropertyDescriptor(
document,
'fullscreenElement'
);
Object.defineProperty(document, 'fullscreenElement', {
configurable: true,
value: shell,
});
try {
escape();
} finally {
if (descriptor)
Object.defineProperty(
document,
'fullscreenElement',
descriptor
);
else Reflect.deleteProperty(document, 'fullscreenElement');
}
expect(host.backRequests).toBe(0);
expect(host.closeRequests).toBe(0);
}
);
it('leaves Escape to a player menu even when focus remains on its trigger', () => {
host.playbackActive.set(true);
fixture.detectChanges();
const menu = document.createElement('div');
menu.setAttribute('role', 'menu');
requiredQuery('.fake-player').append(menu);
requiredQuery('app-portal-detail-shell').dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Escape',
bubbles: true,
cancelable: true,
})
);
expect(host.closeRequests).toBe(0);
});
});
@@ -3,13 +3,17 @@ import {
afterNextRender,
Component,
ElementRef,
Injector,
computed,
contentChild,
effect,
inject,
input,
output,
viewChild,
} from '@angular/core';
import { MatIconModule } from '@angular/material/icon';
import { TranslateModule } from '@ngx-translate/core';
import { ContentHeroComponent } from '../content-hero/content-hero.component';
import { ContentAboutComponent } from './content-about.component';
import {
@@ -34,7 +38,13 @@ import {
@Component({
selector: 'app-portal-detail-shell',
standalone: true,
imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet],
imports: [
ContentHeroComponent,
ContentAboutComponent,
NgTemplateOutlet,
MatIconModule,
TranslateModule,
],
templateUrl: './portal-detail-shell.component.html',
styleUrls: ['./portal-detail-shell.component.scss'],
host: {
@@ -42,12 +52,18 @@ import {
role: 'region',
'[attr.aria-label]': 'title() || backLabel()',
'(keydown)': 'onScrollKey($event)',
// Handle descendant shortcuts before Material tooltips consume them
// on body, while preserving already-handled events and overlay guards.
'(keydown.escape)': 'onEscape($event)',
'[class.shell-host--watch]': 'isWatch()',
'(document:keydown.escape)': 'onEscape($event)',
},
})
export class PortalDetailShellComponent {
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
private readonly injector = inject(Injector);
private readonly backButton =
viewChild<ElementRef<HTMLButtonElement>>('backButton');
readonly title = input<string>();
readonly description = input<string>();
@@ -56,11 +72,13 @@ export class PortalDetailShellComponent {
readonly isLoading = input(false);
readonly errorMessage = input<string>();
readonly backLabel = input<string>();
/** False for hosts whose browse state has no parent navigation. */
readonly backAvailable = input(true);
/** True while inline playback is active — flips the layout to watch state. */
readonly playbackActive = input(false);
readonly backClicked = output<void>();
/** Emitted when Escape is pressed during inline playback. */
/** Emitted by the sticky control or Escape during inline playback. */
readonly closePlayerRequested = output<void>();
protected readonly tagsTemplate = contentChild(DetailTagsTemplateDirective);
@@ -117,19 +135,91 @@ export class PortalDetailShellComponent {
}
onEscape(event: Event): void {
if (!this.playbackActive()) return;
if (event.defaultPrevented) return;
const keyboard = event as KeyboardEvent;
const element = this.host.nativeElement;
const document = element.ownerDocument;
if (
event.defaultPrevented ||
keyboard.repeat ||
keyboard.altKey ||
keyboard.ctrlKey ||
keyboard.metaKey ||
keyboard.shiftKey
)
return;
if (!this.playbackActive() && !this.backAvailable()) return;
if (
element.closest('[inert], [hidden], [aria-hidden="true"]') ||
element.checkVisibility?.({ checkVisibilityCSS: true }) === false
)
return;
// Browser fullscreen owns Escape (exits fullscreen first).
if (document.fullscreenElement) return;
const target = event.target as HTMLElement | null;
// Browse navigation belongs to the focused detail. Watch retains its
// existing global close shortcut (M3U selection keeps sidebar focus).
if (!target || (!this.playbackActive() && !element.contains(target)))
return;
if (
target &&
(target.isContentEditable ||
['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName))
) {
target.isContentEditable ||
target.closest(
'input, textarea, select, [contenteditable]:not([contenteditable="false"])'
)
)
return;
// Menus may keep focus on their trigger. Their own Escape listener
// closes them; do not also dismiss the page/player behind them.
const overlaySelector =
'[role="dialog"], [role="alertdialog"], [role="menu"], [role="listbox"]';
if (
event
.composedPath()
.some(
(node) =>
node instanceof Element && node.matches(overlaySelector)
)
)
return;
if (document.querySelector('.cdk-overlay-backdrop')) return;
if (
Array.from(
document.querySelectorAll<HTMLElement>(overlaySelector)
).some(
(overlay) =>
!overlay.closest(
'[hidden], [inert], [aria-hidden="true"]'
) &&
overlay.checkVisibility?.({ checkVisibilityCSS: true }) !==
false
)
)
return;
event.preventDefault();
this.onBack();
}
onBack(): void {
if (!this.playbackActive()) {
if (this.backAvailable()) this.backClicked.emit();
return;
}
this.closePlayerRequested.emit();
afterNextRender(
() => {
const element = this.host.nativeElement;
if (
element.isConnected &&
!element.closest('[inert]') &&
element.ownerDocument.activeElement ===
element.ownerDocument.body
) {
(this.backButton()?.nativeElement ?? element).focus({
preventScroll: true,
});
}
},
{ injector: this.injector }
);
}
private scrollToTop(): void {