mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -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
@@ -0,0 +1,7 @@
|
||||
---
|
||||
type: fix
|
||||
area: ui
|
||||
issues: [1570]
|
||||
---
|
||||
|
||||
Movie and series detail pages keep their Back button visible while scrolling. Escape closes the inline player to the description, then returns to the previous view. Open menus, dialogs and fullscreen retain priority.
|
||||
@@ -315,7 +315,13 @@ row Enter/Space activation stays separate from focus movement. Portal Live TV
|
||||
uses ArrowRight from the selected category and ArrowLeft from the channels
|
||||
pane to move between columns. Shared live sidebars reserve scrollbar space
|
||||
beside the resize handle. `PortalDetailShellComponent` owns a visible native
|
||||
scrollbar and guarded initial page focus. Contracts:
|
||||
scrollbar and guarded initial page focus. Its sticky control and Escape close
|
||||
inline playback to browse, then invoke the host's existing Back action; the
|
||||
now-playing bar retains its separate direct route Back. Browse Escape requires
|
||||
focus inside the shell; watch preserves the global close shortcut. Menus,
|
||||
dialogs, fullscreen, editable fields, repeats and hidden/inert surfaces retain
|
||||
their keys. M3U and collection bootstrap shells set `backAvailable=false` when
|
||||
there is no browse return action. Contracts:
|
||||
`docs/architecture/iptvnator-ui-guidelines.md` and
|
||||
`docs/architecture/portal-detail-navigation.md`.
|
||||
|
||||
|
||||
@@ -1860,7 +1860,13 @@ row Enter/Space activation stays separate from focus movement. Portal Live TV
|
||||
uses ArrowRight from the selected category and ArrowLeft from the channels
|
||||
pane to move between columns. Shared live sidebars reserve scrollbar space
|
||||
beside the resize handle. `PortalDetailShellComponent` owns a visible native
|
||||
scrollbar and guarded initial page focus. Contracts:
|
||||
scrollbar and guarded initial page focus. Its sticky control and Escape close
|
||||
inline playback to browse, then invoke the host's existing Back action; the
|
||||
now-playing bar retains its separate direct route Back. Browse Escape requires
|
||||
focus inside the shell; watch preserves the global close shortcut. Menus,
|
||||
dialogs, fullscreen, editable fields, repeats and hidden/inert surfaces retain
|
||||
their keys. M3U and collection bootstrap shells set `backAvailable=false` when
|
||||
there is no browse return action. Contracts:
|
||||
`docs/architecture/iptvnator-ui-guidelines.md` and
|
||||
`docs/architecture/portal-detail-navigation.md`.
|
||||
|
||||
|
||||
@@ -590,9 +590,12 @@ async function firstVisibleGridTitle(page: Page): Promise<string> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: browse uses the sticky Back, watch uses the
|
||||
// now-playing bar's direct Back (the sticky watch action is Close player).
|
||||
const backButton = page
|
||||
.locator('app-content-hero .hero__back-button')
|
||||
.first();
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Back', exact: true });
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
|
||||
@@ -97,7 +97,10 @@ test.describe('Dashboard Activation', () => {
|
||||
await app.mainWindow
|
||||
.getByTestId('dashboard-favorite-vod-rail-manage-all')
|
||||
.click();
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/global-favorites$/
|
||||
);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
|
||||
await app.mainWindow.goBack();
|
||||
@@ -223,7 +226,10 @@ const xtreamCredentials = {
|
||||
password: defaultXtreamPassword,
|
||||
};
|
||||
|
||||
async function expectDashboardRail(page: Page, railTestId: string): Promise<void> {
|
||||
async function expectDashboardRail(
|
||||
page: Page,
|
||||
railTestId: string
|
||||
): Promise<void> {
|
||||
await expect(page.locator(`[data-test-id="${railTestId}"]`)).toBeVisible({
|
||||
timeout: 20000,
|
||||
});
|
||||
@@ -234,15 +240,21 @@ function dashboardRailCardByTitle(
|
||||
railTestId: string,
|
||||
title: string
|
||||
) {
|
||||
return page.locator(`[data-test-id="${railTestId}-card"]`).filter({
|
||||
hasText: title,
|
||||
}).first();
|
||||
return page
|
||||
.locator(`[data-test-id="${railTestId}-card"]`)
|
||||
.filter({
|
||||
hasText: title,
|
||||
})
|
||||
.first();
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: browse uses the sticky Back, watch uses the
|
||||
// now-playing bar's direct Back (the sticky watch action is Close player).
|
||||
const backButton = page
|
||||
.locator('app-content-hero .hero__back-button')
|
||||
.first();
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Back', exact: true });
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
@@ -282,7 +294,7 @@ async function expectInlineCollectionDetail(
|
||||
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
|
||||
await expect(page.locator('app-content-hero')).toContainText(params.title);
|
||||
await expect(
|
||||
page.locator('app-content-hero .hero__back-button').first()
|
||||
page.locator('app-portal-detail-shell .shell__back-button').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -351,7 +351,10 @@ test.describe('Electron Favorites', () => {
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/global-favorites$/
|
||||
);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
@@ -366,7 +369,10 @@ test.describe('Electron Favorites', () => {
|
||||
});
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/global-favorites$/
|
||||
);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
} finally {
|
||||
@@ -545,7 +551,10 @@ test.describe('Electron Favorites', () => {
|
||||
await expectInlinePlayerWithoutDialog(app.mainWindow);
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/global-favorites$/
|
||||
);
|
||||
await expectVisibleContentCardTitle(app.mainWindow, movieTitle);
|
||||
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
@@ -562,7 +571,10 @@ test.describe('Electron Favorites', () => {
|
||||
await expectInlinePlayerWithoutDialog(app.mainWindow);
|
||||
|
||||
await goBackFromDetail(app.mainWindow);
|
||||
await expectPathname(app.mainWindow, /\/workspace\/global-favorites$/);
|
||||
await expectPathname(
|
||||
app.mainWindow,
|
||||
/\/workspace\/global-favorites$/
|
||||
);
|
||||
await switchUnifiedCollectionContent(app.mainWindow, 'Series');
|
||||
await expectVisibleContentCardTitle(app.mainWindow, seriesTitle);
|
||||
} finally {
|
||||
@@ -594,9 +606,12 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: browse uses the sticky Back, watch uses the
|
||||
// now-playing bar's direct Back (the sticky watch action is Close player).
|
||||
const backButton = page
|
||||
.locator('app-content-hero .hero__back-button')
|
||||
.first();
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Back', exact: true });
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
@@ -623,7 +638,7 @@ async function expectInlineCollectionDetail(
|
||||
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
|
||||
await expect(page.locator('app-content-hero')).toContainText(params.title);
|
||||
await expect(
|
||||
page.locator('app-content-hero .hero__back-button').first()
|
||||
page.locator('app-portal-detail-shell .shell__back-button').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -74,7 +74,9 @@ test.describe('Electron Recently Viewed', () => {
|
||||
'Stable Recent Channel'
|
||||
).first();
|
||||
const livePlayer = app.mainWindow
|
||||
.locator('app-unified-live-tab .content-container .video-player')
|
||||
.locator(
|
||||
'app-unified-live-tab .content-container .video-player'
|
||||
)
|
||||
.first();
|
||||
|
||||
await item.click();
|
||||
@@ -129,7 +131,10 @@ test.describe('Electron Recently Viewed', () => {
|
||||
.poll(() => visibleLiveTitles(app.mainWindow))
|
||||
.toEqual(['Recent Channel Two', 'Recent Channel One']);
|
||||
|
||||
await toggleFavoriteForChannel(app.mainWindow, 'Recent Channel Two');
|
||||
await toggleFavoriteForChannel(
|
||||
app.mainWindow,
|
||||
'Recent Channel Two'
|
||||
);
|
||||
await expect
|
||||
.poll(() => visibleLiveTitles(app.mainWindow))
|
||||
.toEqual(['Recent Channel Two', 'Recent Channel One']);
|
||||
@@ -648,9 +653,12 @@ async function expectUnifiedLiveDetailOpen(
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: browse uses the sticky Back, watch uses the
|
||||
// now-playing bar's direct Back (the sticky watch action is Close player).
|
||||
const backButton = page
|
||||
.locator('app-content-hero .hero__back-button')
|
||||
.first();
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Back', exact: true });
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
try {
|
||||
@@ -677,7 +685,7 @@ async function expectInlineCollectionDetail(
|
||||
await expect(page.locator('app-workspace-context-panel')).toHaveCount(0);
|
||||
await expect(page.locator('app-content-hero')).toContainText(params.title);
|
||||
await expect(
|
||||
page.locator('app-content-hero .hero__back-button').first()
|
||||
page.locator('app-portal-detail-shell .shell__back-button').first()
|
||||
).toBeVisible({ timeout: 20000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -1540,9 +1540,12 @@ async function addCurrentDetailToFavorites(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
async function goBackFromDetail(page: Page): Promise<void> {
|
||||
// Return to the list: browse uses the sticky Back, watch uses the
|
||||
// now-playing bar's direct Back (the sticky watch action is Close player).
|
||||
const backButton = page
|
||||
.locator('app-content-hero .hero__back-button')
|
||||
.first();
|
||||
.locator('app-portal-detail-shell')
|
||||
.first()
|
||||
.getByRole('button', { name: 'Back', exact: true });
|
||||
|
||||
await expect(backButton).toBeVisible({ timeout: 20000 });
|
||||
await backButton.click();
|
||||
|
||||
@@ -135,6 +135,46 @@ test.describe('Xtream VOD Details', () => {
|
||||
});
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
test(`detail action tooltips preserve one-press Escape (${theme})`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
}) => {
|
||||
await resetMockServers(request, ['xtream']);
|
||||
const app = await launchElectronApp(dataDir);
|
||||
try {
|
||||
const page = app.mainWindow;
|
||||
await addXtreamPortal(page);
|
||||
await waitForXtreamWorkspaceReady(page);
|
||||
await page
|
||||
.getByRole('link', { name: 'Movies', exact: true })
|
||||
.click();
|
||||
await page.evaluate(
|
||||
(dark) => document.body.classList.toggle('dark-theme', dark),
|
||||
theme === 'dark'
|
||||
);
|
||||
for (const action of [
|
||||
'vod-favorite-toggle',
|
||||
'vod-download-start',
|
||||
]) {
|
||||
await page.locator('app-grid-list mat-card').first().click();
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
await expect(
|
||||
shell.getByRole('heading', { level: 1 })
|
||||
).toBeVisible();
|
||||
const button = shell.locator(`[data-testid="${action}"]`);
|
||||
await button.focus();
|
||||
await button.hover();
|
||||
await expect(
|
||||
page.locator('.mat-mdc-tooltip-show')
|
||||
).toBeVisible();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(shell).toHaveCount(0);
|
||||
}
|
||||
} finally {
|
||||
await closeElectronApp(app);
|
||||
}
|
||||
});
|
||||
|
||||
test(`supports keyboard and mouse scrolling in portal details (${theme})`, async ({
|
||||
dataDir,
|
||||
request,
|
||||
@@ -188,7 +228,9 @@ for (const theme of ['light', 'dark']) {
|
||||
.poll(() => shell.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
await page.keyboard.press('Tab');
|
||||
await expect(shell.locator('.hero__back-button')).toBeFocused();
|
||||
await expect(
|
||||
shell.locator('.shell__back-button')
|
||||
).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
await expect(shell).toHaveCount(0);
|
||||
}
|
||||
|
||||
@@ -250,6 +250,17 @@ test('@web @m3u @tmdb browse and watch keep the adjusted volume', async ({
|
||||
)
|
||||
)
|
||||
.toBe(0.25);
|
||||
// M3U has no browse Back target, but the sticky watch control can close it.
|
||||
const shell = detail(page).locator('app-portal-detail-shell');
|
||||
await shell
|
||||
.getByRole('button', { name: 'Close player', exact: true })
|
||||
.first()
|
||||
.click();
|
||||
await expect(inlineVideo(page)).toHaveCount(0);
|
||||
await expect(shell.locator('.shell__back-button')).toHaveCount(0);
|
||||
await shell.focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(playButton).toBeVisible();
|
||||
});
|
||||
|
||||
for (const theme of ['light', 'dark']) {
|
||||
|
||||
@@ -1218,6 +1218,15 @@ test('@stalker series watched toggle — embedded series marks and clears from t
|
||||
await expect(seriesToggle).toContainText(
|
||||
`Mark series as watched (${episodeCount})`
|
||||
);
|
||||
// The menu owns the first Escape; inline Stalker detail owns the next.
|
||||
const detailUrl = page.url();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(seriesToggle).toBeHidden();
|
||||
await menuTrigger.focus();
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(page.locator('app-portal-detail-shell')).toHaveCount(0);
|
||||
await expect(page).toHaveURL(detailUrl);
|
||||
await expect(card).toBeVisible();
|
||||
});
|
||||
|
||||
test('@stalker series — seasons load for a series item', async ({
|
||||
|
||||
@@ -1400,5 +1400,66 @@ for (const theme of ['light', 'dark']) {
|
||||
.poll(() => shell.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test(`@xtream sticky detail Back ${section} (${theme})`, async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
await page.setViewportSize({ width: 1200, height: 540 });
|
||||
await addXtreamPortal(page);
|
||||
await page
|
||||
.getByRole('link', { name: section, exact: true })
|
||||
.click();
|
||||
await page.locator('app-grid-list mat-card').first().click();
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
await expect(
|
||||
shell.getByRole('heading', { level: 1 })
|
||||
).toBeVisible();
|
||||
await shell.focus();
|
||||
await page.evaluate(
|
||||
(dark) => document.body.classList.toggle('dark-theme', dark),
|
||||
theme === 'dark'
|
||||
);
|
||||
const back = shell.getByRole('button', {
|
||||
name: 'Back',
|
||||
exact: true,
|
||||
});
|
||||
await expect(back).toBeVisible();
|
||||
const backOffset = () =>
|
||||
back.evaluate((el) => {
|
||||
const owner = el.closest('app-portal-detail-shell');
|
||||
return owner
|
||||
? el.getBoundingClientRect().top -
|
||||
owner.getBoundingClientRect().top
|
||||
: NaN;
|
||||
});
|
||||
await expect.poll(backOffset).toBeCloseTo(16, 0);
|
||||
await page.keyboard.press('End');
|
||||
await expect
|
||||
.poll(() => shell.evaluate((el) => el.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
await waitForScrollIdle(shell);
|
||||
await expect.poll(backOffset).toBeCloseTo(16, 0);
|
||||
await expect(back).toBeInViewport();
|
||||
await shell.screenshot({
|
||||
path: testInfo.outputPath(
|
||||
`sticky-back-${section}-${theme}.png`
|
||||
),
|
||||
});
|
||||
await back.click();
|
||||
await expect(shell).toHaveCount(0);
|
||||
await page.locator('app-grid-list mat-card').first().click();
|
||||
await expect(
|
||||
shell.getByRole('heading', { level: 1 })
|
||||
).toBeVisible();
|
||||
await shell.focus();
|
||||
await page.keyboard.press('End');
|
||||
// Hover/focus must not make a tooltip consume the advertised Esc.
|
||||
await page.clock.install();
|
||||
await back.focus();
|
||||
await back.hover();
|
||||
await page.clock.runFor(500);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(shell).toHaveCount(0);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -162,9 +162,12 @@ Contracts:
|
||||
player subtree, so shell state changes cannot recreate the `<video>`.
|
||||
- **External MPV/VLC sessions do not flip the layout to watch** — browse
|
||||
layout stays, and the primary CTA keeps its "Stop <player>" behavior.
|
||||
- Escape closes inline playback: the shell emits `closePlayerRequested`
|
||||
when playback is active, the event was not `defaultPrevented`, and no
|
||||
element is in browser fullscreen; hosts wire it to `closeInlinePlayer()`.
|
||||
- The shell's sticky control and Escape close inline playback through
|
||||
`closePlayerRequested`; hosts wire it to `closeInlinePlayer()`. From browse,
|
||||
they emit the host-owned `backClicked` instead (unless `backAvailable=false`).
|
||||
Escape respects fullscreen, menus/dialogs, editable fields and hidden/inert
|
||||
surfaces, and consumes a handled key so one press performs only one action.
|
||||
See [Portal Detail Navigation](./portal-detail-navigation.md).
|
||||
- The now-playing bar separates two exits: the back arrow emits
|
||||
`backClicked`, which hosts wire to their route-level `goBack()` (straight
|
||||
back to the list — everything browse offers is also visible in watch, so
|
||||
|
||||
@@ -23,6 +23,32 @@ do not reach global player shortcuts. Descendant controls retain their native
|
||||
keys and Tab order. Entering watch still scrolls to the top; Back and saved
|
||||
catalog scroll positions retain the existing navigation contract below.
|
||||
|
||||
The shell owns a single sticky Back control, outside the collapsing hero. Its
|
||||
zero-height wrapper is a direct child of the scroll owner, so the control stays
|
||||
16 px from the top throughout long episode lists without shifting the hero.
|
||||
The button has an opaque app-themed surface, visible keyboard focus, an Escape
|
||||
shortcut hint via native `title` and Electron `no-drag` hit testing. The hint
|
||||
does not create an overlay that could consume the first Escape press.
|
||||
|
||||
The sticky control and Escape unwind one level: watch emits
|
||||
`closePlayerRequested`, browse emits `backClicked`. Hosts retain their existing
|
||||
route/inline/collection return behavior. The now-playing bar's separate route
|
||||
Back action still returns directly to the list. Browse Escape requires focus
|
||||
inside this shell; watch keeps the existing global close shortcut, including
|
||||
M3U playback started from its sidebar. Handled events, key repeats/modifiers,
|
||||
editable fields, inert/hidden shells, fullscreen, dialogs and menus are ignored.
|
||||
Escape bubbles through the shell before Material's body-level tooltip dispatcher,
|
||||
so focused detail actions return with one press even while their tooltip is open.
|
||||
The document listener remains the outside-shell watch fallback; `defaultPrevented`
|
||||
prevents duplicate actions and preserves descendant handlers' priority.
|
||||
After closing a player, lost focus moves to the sticky control (or the shell
|
||||
when there is no browse Back), without scrolling or stealing existing focus.
|
||||
|
||||
Hosts without browse navigation set `backAvailable=false`: M3U uses its channel
|
||||
sidebar, and collection bootstrap placeholders have no return handler. They
|
||||
have no browse button or browse Escape action; M3U watch still offers Close
|
||||
player. Loading/error shells with a return handler keep Back available.
|
||||
|
||||
## Summary
|
||||
|
||||
- Xtream category browsing uses a route-first detail model.
|
||||
@@ -30,7 +56,8 @@ catalog scroll positions retain the existing navigation contract below.
|
||||
- Detail pages themselves are two-state (browse ↔ watch) inside
|
||||
`PortalDetailShellComponent`; entering/leaving watch is a layout state,
|
||||
not a navigation. Route-level back semantics are unchanged; the
|
||||
watch-state back button only closes the inline player. See
|
||||
sticky watch control closes the inline player, while the now-playing bar
|
||||
retains its separate direct return to the list. See
|
||||
[Embedded Inline Playback](./embedded-inline-playback.md).
|
||||
- Favorites and recently viewed collections now use collection-owned inline detail
|
||||
for non-live Xtream and Stalker items.
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<app-portal-detail-shell
|
||||
[backAvailable]="false"
|
||||
[title]="title()"
|
||||
[description]="overview()"
|
||||
[posterUrl]="posterUrl()"
|
||||
|
||||
@@ -15,12 +15,3 @@ app-portal-detail-shell {
|
||||
display: block;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
// The M3U player page has no route to go "back" to — the channel sidebar
|
||||
// next to the detail IS the navigation — so the hero's back arrow would
|
||||
// dead-end. Hidden here instead of a shell option: this host is the only
|
||||
// consumer without a parent route. `:host` scoping is load-bearing — a bare
|
||||
// `::ng-deep` would leak the rule globally and hide the portals' back arrow.
|
||||
:host ::ng-deep .hero__back-button {
|
||||
display: none;
|
||||
}
|
||||
+1
@@ -31,6 +31,7 @@ class StubPortalDetailShellComponent {
|
||||
readonly isLoading = input(false);
|
||||
readonly errorMessage = input<string>();
|
||||
readonly backLabel = input<string>();
|
||||
readonly backAvailable = input(true);
|
||||
readonly playbackActive = input(false);
|
||||
readonly backClicked = output<void>();
|
||||
readonly closePlayerRequested = output<void>();
|
||||
|
||||
+2
-2
@@ -1072,7 +1072,7 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
|
||||
(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.hero__back-button'
|
||||
'.shell__back-button'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await fixture.whenStable();
|
||||
@@ -1092,7 +1092,7 @@ describe('DownloadOfflineDetailComponent', () => {
|
||||
|
||||
(
|
||||
(fixture.nativeElement as HTMLElement).querySelector(
|
||||
'.hero__back-button'
|
||||
'.shell__back-button'
|
||||
) as HTMLButtonElement
|
||||
).click();
|
||||
await fixture.whenStable();
|
||||
|
||||
@@ -80,7 +80,10 @@ import {
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-portal-detail-shell [isLoading]="true" />
|
||||
<app-portal-detail-shell
|
||||
[isLoading]="true"
|
||||
[backAvailable]="false"
|
||||
/>
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -55,7 +55,10 @@ interface XtreamCollectionStateSnapshot {
|
||||
"
|
||||
/>
|
||||
} @else {
|
||||
<app-portal-detail-shell [isLoading]="true" />
|
||||
<app-portal-detail-shell
|
||||
[isLoading]="true"
|
||||
[backAvailable]="false"
|
||||
/>
|
||||
}
|
||||
`,
|
||||
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||
|
||||
@@ -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