mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
fix(ui): keep detail back navigation available while scrolling (#1576)
This commit is contained in:
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)
|
||||
// ============================================================================
|
||||
|
||||
+263
-15
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user