fix(downloads): harden offline detail interactions

This commit is contained in:
4gray committed 2026-08-01 16:29:50 +02:00
1 parent a741415a6a
commit 211ef784fc
24 files changed
+1417 -255

No files matched your search

@@ -1,8 +1,13 @@
<!-- Hero Section with Backdrop -->
<section class="hero">
<!-- Back Button -->
<button class="hero__back-button" (click)="onBack()">
<mat-icon>arrow_back</mat-icon>
<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">
@@ -1,5 +1,5 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { TranslateModule } from '@ngx-translate/core';
import { TranslateModule, TranslateService } from '@ngx-translate/core';
import { ContentHeroComponent } from './content-hero.component';
describe('ContentHeroComponent', () => {
@@ -20,6 +20,9 @@ describe('ContentHeroComponent', () => {
}).compileComponents();
fixture = TestBed.createComponent(ContentHeroComponent);
const translate = TestBed.inject(TranslateService);
translate.setTranslation('en', { BACK: 'Go back' });
translate.use('en');
});
it('renders description content when ResizeObserver is unavailable', () => {
@@ -38,4 +41,47 @@ describe('ContentHeroComponent', () => {
expect(host.textContent).toContain('Fallback Title');
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();
const first = (fixture.nativeElement as HTMLElement).querySelector(
'img[src="broken.jpg"]'
) as HTMLImageElement;
first.dispatchEvent(new Event('error'));
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'img[src="broken.jpg"]'
)
).toBeNull();
fixture.componentRef.setInput('posterUrl', 'replacement.jpg');
fixture.detectChanges();
expect(
(fixture.nativeElement as HTMLElement).querySelector(
'img[src="replacement.jpg"]'
)
).toBeTruthy();
});
});
@@ -37,17 +37,23 @@ 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);
readonly descriptionEl = viewChild<ElementRef<HTMLElement>>('descriptionEl');
readonly descriptionEl =
viewChild<ElementRef<HTMLElement>>('descriptionEl');
readonly isDescriptionExpanded = signal(false);
readonly hasDescriptionOverflow = signal(false);
private resizeObserver?: ResizeObserver;
constructor() {
effect(() => {
this.posterUrl();
untracked(() => this.posterError.set(false));
});
effect(() => {
// Re-measure whenever description content or the element changes.
this.description();
@@ -120,7 +126,9 @@ export class ContentHeroComponent {
this.measureOverflow(el);
return;
}
this.resizeObserver = new ResizeObserver(() => this.measureOverflow(el));
this.resizeObserver = new ResizeObserver(() =>
this.measureOverflow(el)
);
this.resizeObserver.observe(el);
}
}
@@ -8,6 +8,7 @@
[backdropUrl]="backdropUrl()"
[isLoading]="isLoading()"
[errorMessage]="errorMessage()"
[backLabel]="backLabel()"
(backClicked)="backClicked.emit()"
>
<ng-container hero-tags>
@@ -21,6 +21,7 @@ import { PortalDetailShellComponent } from './portal-detail-shell.component';
[title]="'Show Title'"
[description]="'Show description'"
[posterUrl]="'poster.jpg'"
[backLabel]="'Return to downloads'"
[playbackActive]="playbackActive()"
(closePlayerRequested)="closeRequests = closeRequests + 1"
>
@@ -72,6 +73,9 @@ 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(
'Return to downloads'
);
});
it('collapses hero and shows About with re-stamped templates in watch state', () => {
@@ -50,6 +50,7 @@ export class PortalDetailShellComponent {
readonly backdropUrl = input<string>();
readonly isLoading = input(false);
readonly errorMessage = input<string>();
readonly backLabel = input<string>();
/** True while inline playback is active — flips the layout to watch state. */
readonly playbackActive = input(false);