mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-14 21:26:15 -08:00
Merge remote-tracking branch 'origin/master' into claude/iptvnator-vod-hero-redesign-2c8950
# Conflicts: # apps/electron-backend-e2e/src/detail-back-lane.e2e.ts # apps/web-e2e/src/m3u-movie-details.e2e.ts # docs/architecture/iptvnator-ui-guidelines.md # libs/ui/components/src/lib/content-hero/content-hero.component.scss # libs/ui/components/src/lib/portal-detail-shell/portal-detail-shell.component.scss
This commit is contained in:
commit
87d7e1d46c
122 files changed
+2080
-1040
No files matched your search
+5
-1
@@ -59,7 +59,11 @@
|
||||
|
||||
<mat-dialog-actions align="end">
|
||||
@if (currentMapping()) {
|
||||
<button mat-button color="warn" (click)="removeMapping()">
|
||||
<button
|
||||
mat-button
|
||||
class="app-destructive-button"
|
||||
(click)="removeMapping()"
|
||||
>
|
||||
{{ 'EPG_MAPPING_DIALOG.REMOVE' | translate }}
|
||||
</button>
|
||||
}
|
||||
|
||||
@@ -52,3 +52,49 @@ describe('ConfirmDialogComponent actions', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
describe('ConfirmDialogComponent labels and tone', () => {
|
||||
async function render(data: Record<string, unknown>) {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ConfirmDialogComponent,
|
||||
NoopAnimationsModule,
|
||||
TranslateModule.forRoot(),
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: MAT_DIALOG_DATA,
|
||||
useValue: {
|
||||
title: 'Remove playlist',
|
||||
message: 'Delete it?',
|
||||
confirmLabel: 'Remove playlist',
|
||||
onConfirm: jest.fn(),
|
||||
...data,
|
||||
},
|
||||
},
|
||||
{ provide: MatDialogRef, useValue: { close: jest.fn() } },
|
||||
],
|
||||
}).compileComponents();
|
||||
const fixture = TestBed.createComponent(ConfirmDialogComponent);
|
||||
fixture.detectChanges();
|
||||
const buttons = fixture.nativeElement.querySelectorAll(
|
||||
'button'
|
||||
) as NodeListOf<HTMLButtonElement>;
|
||||
return { cancel: buttons[0], confirm: buttons[1] };
|
||||
}
|
||||
|
||||
it('names the action instead of answering "Yes", and cancels by default', async () => {
|
||||
const { cancel, confirm } = await render({});
|
||||
|
||||
expect(confirm.textContent?.trim()).toBe('Remove playlist');
|
||||
// No translations are loaded, so the key is the rendered text.
|
||||
expect(cancel.textContent?.trim()).toBe('CANCEL');
|
||||
expect(confirm.classList).not.toContain('app-destructive-button');
|
||||
});
|
||||
|
||||
it('styles destructive confirmations as destructive', async () => {
|
||||
const { confirm } = await render({ tone: 'destructive' });
|
||||
|
||||
expect(confirm.classList).toContain('app-destructive-button');
|
||||
});
|
||||
});
|
||||
@@ -3,12 +3,25 @@ import { MatButtonModule } from '@angular/material/button';
|
||||
import { MAT_DIALOG_DATA, MatDialogModule } from '@angular/material/dialog';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
|
||||
/**
|
||||
* `destructive` renders the confirm action with the app-wide error styling
|
||||
* (`.app-destructive-button`). Use it whenever confirming removes or discards
|
||||
* user data; Material's `warn` color input has no effect with the M3 theme.
|
||||
*/
|
||||
export type ConfirmDialogTone = 'default' | 'destructive';
|
||||
|
||||
export interface ConfirmDialogData {
|
||||
title: string;
|
||||
message: string;
|
||||
width?: string;
|
||||
confirmLabel?: string;
|
||||
/**
|
||||
* Translated verb that names the action ("Remove playlist", "Clear").
|
||||
* Required so a confirmation never falls back to an ambiguous "Yes".
|
||||
*/
|
||||
confirmLabel: string;
|
||||
/** Translated dismiss label; defaults to "Cancel". */
|
||||
cancelLabel?: string;
|
||||
tone?: ConfirmDialogTone;
|
||||
/** Run the action in the dialog; only Close/backdrop/Escape dismiss it. */
|
||||
keepOpenOnConfirm?: boolean;
|
||||
onConfirm: () => void;
|
||||
@@ -27,23 +40,25 @@ export interface ConfirmDialogData {
|
||||
</mat-dialog-content>
|
||||
<mat-dialog-actions align="end">
|
||||
<button mat-button mat-dialog-close cdkFocusInitial>
|
||||
{{ dialogData.cancelLabel || 'NO' | translate }}
|
||||
{{ dialogData.cancelLabel || ('CANCEL' | translate) }}
|
||||
</button>
|
||||
@if (dialogData.keepOpenOnConfirm) {
|
||||
<button
|
||||
mat-flat-button
|
||||
data-test-id="confirm-dialog-confirm"
|
||||
[class.app-destructive-button]="isDestructive"
|
||||
(click)="dialogData.onConfirm()"
|
||||
color="primary"
|
||||
>
|
||||
{{ dialogData.confirmLabel || 'YES' | translate }}
|
||||
{{ dialogData.confirmLabel }}
|
||||
</button>
|
||||
} @else {
|
||||
<button
|
||||
mat-flat-button
|
||||
data-test-id="confirm-dialog-confirm"
|
||||
[class.app-destructive-button]="isDestructive"
|
||||
[mat-dialog-close]="true"
|
||||
color="primary"
|
||||
>
|
||||
{{ dialogData.confirmLabel || 'YES' | translate }}
|
||||
{{ dialogData.confirmLabel }}
|
||||
</button>
|
||||
}
|
||||
</mat-dialog-actions>
|
||||
@@ -56,4 +71,8 @@ export class ConfirmDialogComponent {
|
||||
constructor() {
|
||||
this.dialogData = this.data;
|
||||
}
|
||||
|
||||
get isDestructive(): boolean {
|
||||
return this.dialogData.tone === 'destructive';
|
||||
}
|
||||
}
|
||||
@@ -33,6 +33,7 @@ describe('DialogService', () => {
|
||||
service.openConfirmDialog({
|
||||
title: 'Remove',
|
||||
message: 'Confirm removal?',
|
||||
confirmLabel: 'Remove playlist',
|
||||
onConfirm: jest.fn(),
|
||||
});
|
||||
|
||||
@@ -57,6 +58,7 @@ describe('DialogService', () => {
|
||||
service.openConfirmDialog({
|
||||
title: 'Refresh Xtream Playlist',
|
||||
message: 'Confirm refresh?',
|
||||
confirmLabel: 'Refresh playlist',
|
||||
width: '400px',
|
||||
onConfirm: jest.fn(),
|
||||
});
|
||||
@@ -78,6 +80,7 @@ describe('DialogService', () => {
|
||||
service.openConfirmDialog({
|
||||
title: 'Remove',
|
||||
message: 'Confirm removal?',
|
||||
confirmLabel: 'Remove playlist',
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
@@ -89,6 +92,7 @@ describe('DialogService', () => {
|
||||
service.openConfirmDialog({
|
||||
title: 'Recovery',
|
||||
message: 'Path',
|
||||
confirmLabel: 'Copy path',
|
||||
keepOpenOnConfirm: true,
|
||||
onConfirm,
|
||||
});
|
||||
|
||||
@@ -157,8 +157,6 @@
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 48px 32px 24px;
|
||||
// Reserved by the detail shell while its sticky Back control shows.
|
||||
padding-inline-start: max(32px, var(--detail-back-lane, 0px));
|
||||
box-sizing: border-box;
|
||||
|
||||
&--skeleton {
|
||||
@@ -435,7 +433,6 @@
|
||||
|
||||
.hero__content {
|
||||
padding: 40px 20px 24px;
|
||||
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
|
||||
}
|
||||
|
||||
.details {
|
||||
@@ -450,7 +447,6 @@
|
||||
@media (max-width: 640px) {
|
||||
.hero__content {
|
||||
padding-inline: 16px;
|
||||
padding-inline-start: max(16px, var(--detail-back-lane, 0px));
|
||||
}
|
||||
|
||||
.details__title {
|
||||
|
||||
@@ -6,8 +6,6 @@
|
||||
max-width: var(--content-max-width, 1400px);
|
||||
margin: 0 auto;
|
||||
padding: 24px 40px 40px;
|
||||
// The detail shell's sticky Back lane (0 when there is no Back).
|
||||
padding-inline-start: max(40px, var(--detail-back-lane, 0px));
|
||||
border-top: 1px solid var(--tag-border, rgba(255, 255, 255, 0.12));
|
||||
|
||||
&__heading {
|
||||
@@ -129,7 +127,6 @@
|
||||
@media (max-width: 800px) {
|
||||
.about {
|
||||
padding: 20px 20px 32px;
|
||||
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
|
||||
|
||||
&__poster {
|
||||
width: 92px;
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
@if (backAvailable()) {
|
||||
@let label = backLabel() || ('BACK' | translate);
|
||||
<!-- Route-level Back in both states. Watch keeps the arrow's meaning;
|
||||
closing the player belongs to the player's own Close button and Escape. -->
|
||||
<div class="shell__navigation">
|
||||
<button
|
||||
#backButton
|
||||
class="shell__back-button"
|
||||
type="button"
|
||||
[attr.aria-label]="label"
|
||||
[attr.aria-keyshortcuts]="isWatch() ? null : 'Escape'"
|
||||
[title]="isWatch() ? label : label + ' (Esc)'"
|
||||
(click)="backClicked.emit()"
|
||||
>
|
||||
<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">
|
||||
|
||||
@@ -13,13 +13,6 @@
|
||||
--tag-border: color-mix(in srgb, var(--app-on-surface, #1d1b1e) 14%, transparent);
|
||||
--poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.3);
|
||||
--content-max-width: 1400px;
|
||||
// The sticky Back control floats over the scroll owner. While it is shown,
|
||||
// every content column (hero, player, episodes, About, extras) starts
|
||||
// after its lane — inset + button + the same inset again — so nothing
|
||||
// scrolls beneath it. content-hero and content-about read the lane too.
|
||||
--shell-back-inset: 16px;
|
||||
--shell-back-size: 40px;
|
||||
--detail-back-lane: 0px;
|
||||
|
||||
display: block;
|
||||
height: 100%;
|
||||
@@ -40,58 +33,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
:host(.shell-host--back) {
|
||||
--detail-back-lane: calc(
|
||||
2 * var(--shell-back-inset) + var(--shell-back-size)
|
||||
);
|
||||
}
|
||||
|
||||
:host-context(.dark-theme) {
|
||||
--poster-shadow: 0 18px 40px rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
|
||||
.shell__navigation {
|
||||
// Direct child of the scroll owner: sticky lasts through the entire page.
|
||||
position: sticky;
|
||||
top: var(--shell-back-inset);
|
||||
height: 0;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.shell__back-button {
|
||||
position: absolute;
|
||||
inset-inline-start: var(--shell-back-inset);
|
||||
pointer-events: auto;
|
||||
app-region: no-drag;
|
||||
-webkit-app-region: no-drag;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: var(--shell-back-size);
|
||||
height: var(--shell-back-size);
|
||||
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)
|
||||
// ============================================================================
|
||||
@@ -132,7 +77,6 @@
|
||||
|
||||
&--active {
|
||||
padding: 16px 40px 0;
|
||||
padding-inline-start: max(40px, var(--detail-back-lane));
|
||||
animation: shell-fade-in 300ms ease 50ms both;
|
||||
}
|
||||
}
|
||||
@@ -141,7 +85,6 @@
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 0 32px;
|
||||
padding-inline-start: max(32px, var(--detail-back-lane));
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
@@ -162,7 +105,6 @@
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: 8px 32px 40px;
|
||||
padding-inline-start: max(32px, var(--detail-back-lane));
|
||||
|
||||
&:empty {
|
||||
display: none;
|
||||
@@ -183,38 +125,14 @@
|
||||
@media (max-width: 800px) {
|
||||
.shell__player--active {
|
||||
padding: 8px 12px 0;
|
||||
padding-inline-start: max(12px, var(--detail-back-lane));
|
||||
}
|
||||
|
||||
.shell__episodes {
|
||||
padding: 0 20px;
|
||||
padding-inline-start: max(20px, var(--detail-back-lane));
|
||||
}
|
||||
|
||||
.shell__extras {
|
||||
padding: 0 20px 32px;
|
||||
padding-inline-start: max(20px, var(--detail-back-lane));
|
||||
}
|
||||
}
|
||||
|
||||
// A pane too narrow to spare a 72px column (a phone, or a desktop pane beside
|
||||
// the context panel; the component sets the class from the pane's width)
|
||||
// would clip the player's controls. There the control takes a row instead —
|
||||
// an opaque sticky bar in flow that content scrolls beneath, never alongside.
|
||||
:host(.shell-host--compact) {
|
||||
--detail-back-lane: 0px;
|
||||
|
||||
.shell__navigation {
|
||||
top: 0;
|
||||
height: calc(var(--shell-back-size) + 16px);
|
||||
background: var(--app-header-bg);
|
||||
// The bar hides what scrolls beneath it, so it must not let taps
|
||||
// through to that hidden content either.
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.shell__back-button {
|
||||
top: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+32
-91
@@ -1,6 +1,7 @@
|
||||
import { Component, signal } from '@angular/core';
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||
import { TranslateModule, TranslateService } from '@ngx-translate/core';
|
||||
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
|
||||
import {
|
||||
DetailActionsTemplateDirective,
|
||||
DetailMetaTemplateDirective,
|
||||
@@ -69,6 +70,9 @@ describe('PortalDetailShellComponent', () => {
|
||||
return element;
|
||||
};
|
||||
|
||||
const backTarget = () =>
|
||||
TestBed.inject(WorkspaceBackNavigationService).target();
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [HostComponent, TranslateModule.forRoot()],
|
||||
@@ -130,13 +134,13 @@ 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('.shell__back-button')?.getAttribute('aria-label')).toBe(
|
||||
'Return to downloads'
|
||||
);
|
||||
// The header renders Back; the page itself keeps no arrow.
|
||||
expect(query('button[aria-label="Return to downloads"]')).toBeNull();
|
||||
expect(backTarget()?.label()).toBe('Return to downloads');
|
||||
});
|
||||
|
||||
it.each([{ isLoading: true }, { errorMessage: 'Unavailable' }])(
|
||||
'keeps the translated fallback Back available in loading/error states',
|
||||
'keeps the generic Back available in loading/error states',
|
||||
(state) => {
|
||||
fixture.destroy();
|
||||
const shellFixture = TestBed.createComponent(
|
||||
@@ -145,16 +149,14 @@ describe('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');
|
||||
// No host label: the header falls back to the translated "Back".
|
||||
expect(backTarget()?.label()).toBeNull();
|
||||
const back = jest.fn();
|
||||
shellFixture.componentInstance.backClicked.subscribe(back);
|
||||
button?.click();
|
||||
backTarget()?.run();
|
||||
expect(back).toHaveBeenCalledTimes(1);
|
||||
shellFixture.destroy();
|
||||
expect(backTarget()).toBeNull();
|
||||
}
|
||||
);
|
||||
|
||||
@@ -277,30 +279,33 @@ describe('PortalDetailShellComponent', () => {
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps one route-back control outside the collapsing hero in both states', () => {
|
||||
const back = requiredQuery('.shell__back-button');
|
||||
expect(back.closest('app-content-hero')).toBeNull();
|
||||
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
|
||||
expect(back.getAttribute('aria-keyshortcuts')).toBe('Escape');
|
||||
back.click();
|
||||
it('offers one route-level Back to the header in both states', () => {
|
||||
const target = backTarget();
|
||||
expect(target?.label()).toBe('Return to downloads');
|
||||
expect(target?.escapeShortcut()).toBe(true);
|
||||
target?.run();
|
||||
expect(host.backRequests).toBe(1);
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
expect(query('.shell__back-button')).toBe(back);
|
||||
expect(backTarget()).toBe(target);
|
||||
// Watch keeps the arrow's meaning: it leaves the page, it does not
|
||||
// close the player. Escape is the close shortcut, so the hint goes.
|
||||
expect(back.getAttribute('aria-label')).toBe('Return to downloads');
|
||||
expect(back.getAttribute('aria-keyshortcuts')).toBeNull();
|
||||
expect(back.getAttribute('title')).toBe('Return to downloads');
|
||||
back.click();
|
||||
expect(target?.escapeShortcut()).toBe(false);
|
||||
target?.run();
|
||||
expect(host.backRequests).toBe(2);
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
|
||||
it('moves lost focus to the back control after Escape closes the player', async () => {
|
||||
it('releases the header Back when the page goes away', () => {
|
||||
expect(backTarget()).not.toBeNull();
|
||||
fixture.destroy();
|
||||
expect(backTarget()).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps focus on the page after Escape closes the player', async () => {
|
||||
host.playbackActive.set(true);
|
||||
fixture.detectChanges();
|
||||
const back = requiredQuery('.shell__back-button');
|
||||
const shell = requiredQuery('app-portal-detail-shell');
|
||||
const player = requiredQuery('.fake-player');
|
||||
player.tabIndex = 0;
|
||||
player.focus();
|
||||
@@ -315,13 +320,13 @@ describe('PortalDetailShellComponent', () => {
|
||||
await fixture.whenStable();
|
||||
expect(host.closeRequests).toBe(1);
|
||||
expect(host.backRequests).toBe(0);
|
||||
expect(document.activeElement).toBe(back);
|
||||
expect(document.activeElement).toBe(shell);
|
||||
});
|
||||
|
||||
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();
|
||||
expect(backTarget()).toBeNull();
|
||||
requiredQuery('app-portal-detail-shell').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
@@ -334,7 +339,7 @@ describe('PortalDetailShellComponent', () => {
|
||||
fixture.detectChanges();
|
||||
// No route to go back to, so no arrow in watch either; the player's
|
||||
// own Close button and Escape remain the exits.
|
||||
expect(query('.shell__back-button')).toBeNull();
|
||||
expect(backTarget()).toBeNull();
|
||||
requiredQuery('app-portal-detail-shell').dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Escape',
|
||||
@@ -451,67 +456,3 @@ describe('PortalDetailShellComponent', () => {
|
||||
expect(host.closeRequests).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('PortalDetailShellComponent pane width', () => {
|
||||
const original = globalThis.ResizeObserver;
|
||||
const disconnect = jest.fn();
|
||||
// The hero observes itself too, so keep each callback with its target.
|
||||
let observers: { callback: ResizeObserverCallback; target?: Element }[];
|
||||
|
||||
beforeEach(() => {
|
||||
observers = [];
|
||||
disconnect.mockClear();
|
||||
globalThis.ResizeObserver = class {
|
||||
private readonly entry: (typeof observers)[number];
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
this.entry = { callback };
|
||||
observers.push(this.entry);
|
||||
}
|
||||
observe(target: Element): void {
|
||||
this.entry.target = target;
|
||||
}
|
||||
unobserve = jest.fn();
|
||||
disconnect = disconnect;
|
||||
} as unknown as typeof ResizeObserver;
|
||||
TestBed.configureTestingModule({
|
||||
imports: [HostComponent, TranslateModule.forRoot()],
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
globalThis.ResizeObserver = original;
|
||||
});
|
||||
|
||||
const resize = (shell: HTMLElement, inlineSize: number): void => {
|
||||
const observer = observers.find(({ target }) => target === shell);
|
||||
if (!observer) throw new Error('The shell does not observe itself.');
|
||||
observer.callback(
|
||||
[
|
||||
{
|
||||
borderBoxSize: [{ inlineSize, blockSize: 800 }],
|
||||
} as unknown as ResizeObserverEntry,
|
||||
],
|
||||
{} as ResizeObserver
|
||||
);
|
||||
};
|
||||
|
||||
it('trades the Back lane for a bar when the pane itself is narrow', () => {
|
||||
const fixture = TestBed.createComponent(HostComponent);
|
||||
fixture.detectChanges();
|
||||
const shell = (fixture.nativeElement as HTMLElement).querySelector(
|
||||
'app-portal-detail-shell'
|
||||
) as HTMLElement;
|
||||
|
||||
// A 780px window leaves the pane ~402px beside the context panel.
|
||||
resize(shell, 402);
|
||||
expect(shell.classList).not.toContain('shell-host--compact');
|
||||
// A 641px window leaves it ~260px: too narrow for the lane.
|
||||
resize(shell, 260);
|
||||
expect(shell.classList).toContain('shell-host--compact');
|
||||
resize(shell, 1280);
|
||||
expect(shell.classList).not.toContain('shell-host--compact');
|
||||
|
||||
fixture.destroy();
|
||||
expect(disconnect).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,6 @@ import { NgTemplateOutlet } from '@angular/common';
|
||||
import {
|
||||
afterNextRender,
|
||||
Component,
|
||||
DestroyRef,
|
||||
ElementRef,
|
||||
Injector,
|
||||
computed,
|
||||
@@ -11,11 +10,10 @@ import {
|
||||
inject,
|
||||
input,
|
||||
output,
|
||||
viewChild,
|
||||
ChangeDetectionStrategy,
|
||||
} from '@angular/core';
|
||||
import { MatIconModule } from '@angular/material/icon';
|
||||
import { TranslateModule } from '@ngx-translate/core';
|
||||
import { WorkspaceBackNavigationService } from '@iptvnator/portal/shared/data-access';
|
||||
import { WorkspaceBackTarget } from '@iptvnator/portal/shared/util';
|
||||
import { ContentHeroComponent } from '../content-hero/content-hero.component';
|
||||
import { ContentAboutComponent } from './content-about.component';
|
||||
import {
|
||||
@@ -24,14 +22,6 @@ import {
|
||||
DetailTagsTemplateDirective,
|
||||
} from './detail-template.directives';
|
||||
|
||||
/**
|
||||
* Below this pane width the Back lane (16 + 40 + 16px) would leave the player
|
||||
* card under ~316px, where its control row clips, so the control takes a
|
||||
* sticky bar instead. The pane decides, not the viewport: beside the context
|
||||
* panel a desktop pane can be narrower than a phone.
|
||||
*/
|
||||
const COMPACT_SHELL_WIDTH = 400;
|
||||
|
||||
/**
|
||||
* Two-state layout shell for portal VOD/series detail pages.
|
||||
*
|
||||
@@ -41,22 +31,17 @@ const COMPACT_SHELL_WIDTH = 400;
|
||||
* About block below the episodes slot.
|
||||
*
|
||||
* The shell owns the page scroll, the browse↔watch animation, Escape
|
||||
* handling, the one sticky Back control (route-level in both states; closing
|
||||
* the player is the player's own Close button and Escape), and never
|
||||
* conditionally wraps the `[detail-player]` slot — the
|
||||
* host's own `@if (inlinePlayback())` is the only thing that creates or
|
||||
* destroys the player, so shell state changes cannot recreate it.
|
||||
* handling and the page's Back action, which the workspace header renders in
|
||||
* its leading slot (route-level in both states; closing the player is the
|
||||
* player's own Close button and Escape). It never conditionally wraps the
|
||||
* `[detail-player]` slot — the host's own `@if (inlinePlayback())` is the
|
||||
* only thing that creates or destroys the player, so shell state changes
|
||||
* cannot recreate it.
|
||||
*/
|
||||
@Component({
|
||||
selector: 'app-portal-detail-shell',
|
||||
standalone: true,
|
||||
imports: [
|
||||
ContentHeroComponent,
|
||||
ContentAboutComponent,
|
||||
NgTemplateOutlet,
|
||||
MatIconModule,
|
||||
TranslateModule,
|
||||
],
|
||||
imports: [ContentHeroComponent, ContentAboutComponent, NgTemplateOutlet],
|
||||
templateUrl: './portal-detail-shell.component.html',
|
||||
styleUrls: ['./portal-detail-shell.component.scss'],
|
||||
// eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade.
|
||||
@@ -70,17 +55,13 @@ const COMPACT_SHELL_WIDTH = 400;
|
||||
// on body, while preserving already-handled events and overlay guards.
|
||||
'(keydown.escape)': 'onEscape($event)',
|
||||
'[class.shell-host--watch]': 'isWatch()',
|
||||
// Content columns reserve the sticky Back control's lane.
|
||||
'[class.shell-host--back]': 'backAvailable()',
|
||||
'(document:keydown.escape)': 'onEscape($event)',
|
||||
},
|
||||
})
|
||||
export class PortalDetailShellComponent {
|
||||
private readonly host = inject<ElementRef<HTMLElement>>(ElementRef);
|
||||
private readonly injector = inject(Injector);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly backButton =
|
||||
viewChild<ElementRef<HTMLButtonElement>>('backButton');
|
||||
private readonly backNavigation = inject(WorkspaceBackNavigationService);
|
||||
|
||||
readonly title = input<string>();
|
||||
/** "Movie · playlist name" eyebrow above the hero title. */
|
||||
@@ -100,7 +81,7 @@ export class PortalDetailShellComponent {
|
||||
/** True while inline playback is active — flips the layout to watch state. */
|
||||
readonly playbackActive = input(false);
|
||||
|
||||
/** The sticky control in either state, or Escape in browse. */
|
||||
/** The header's Back in either state, or Escape in browse. */
|
||||
readonly backClicked = output<void>();
|
||||
/** Emitted by Escape during inline playback. */
|
||||
readonly closePlayerRequested = output<void>();
|
||||
@@ -113,6 +94,12 @@ export class PortalDetailShellComponent {
|
||||
|
||||
readonly isWatch = computed(() => this.playbackActive());
|
||||
|
||||
private readonly backTarget: WorkspaceBackTarget = {
|
||||
label: computed(() => this.backLabel() || null),
|
||||
escapeShortcut: computed(() => !this.isWatch()),
|
||||
run: () => this.backClicked.emit(),
|
||||
};
|
||||
|
||||
constructor() {
|
||||
afterNextRender(() => {
|
||||
const element = this.host.nativeElement;
|
||||
@@ -126,7 +113,10 @@ export class PortalDetailShellComponent {
|
||||
element.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
this.observeCompactWidth();
|
||||
effect((onCleanup) => {
|
||||
if (!this.backAvailable()) return;
|
||||
onCleanup(this.backNavigation.register(this.backTarget));
|
||||
});
|
||||
let wasWatch = false;
|
||||
effect(() => {
|
||||
const watch = this.isWatch();
|
||||
@@ -233,15 +223,15 @@ export class PortalDetailShellComponent {
|
||||
afterNextRender(
|
||||
() => {
|
||||
const element = this.host.nativeElement;
|
||||
// The page keeps focus, so the next Escape still unwinds it
|
||||
// and the arrow keys still scroll it.
|
||||
if (
|
||||
element.isConnected &&
|
||||
!element.closest('[inert]') &&
|
||||
element.ownerDocument.activeElement ===
|
||||
element.ownerDocument.body
|
||||
) {
|
||||
(this.backButton()?.nativeElement ?? element).focus({
|
||||
preventScroll: true,
|
||||
});
|
||||
element.focus({ preventScroll: true });
|
||||
}
|
||||
},
|
||||
{ injector: this.injector }
|
||||
@@ -260,24 +250,4 @@ export class PortalDetailShellComponent {
|
||||
}
|
||||
element.scrollTo({ top: 0, behavior: 'auto' });
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles `shell-host--compact` straight on the host, so the class lands
|
||||
* in the same frame as the resize instead of after a change-detection
|
||||
* pass. The border box keeps the threshold independent of scrollbar width.
|
||||
*/
|
||||
private observeCompactWidth(): void {
|
||||
if (typeof ResizeObserver === 'undefined') return;
|
||||
const element = this.host.nativeElement;
|
||||
const observer = new ResizeObserver(([entry]) => {
|
||||
const width =
|
||||
entry?.borderBoxSize?.[0]?.inlineSize ?? element.offsetWidth;
|
||||
element.classList.toggle(
|
||||
'shell-host--compact',
|
||||
width < COMPACT_SHELL_WIDTH
|
||||
);
|
||||
});
|
||||
observer.observe(element);
|
||||
this.destroyRef.onDestroy(() => observer.disconnect());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user