fix(detail): keep the Back button from covering detail content (#1763)

This commit is contained in:
4gray authored and GitHub committed 2026-09-30 22:51:17 +02:00
1 parent 525ca7bc44
commit 444ec06e94
12 files changed
+531 -7

No files matched your search

@@ -144,6 +144,8 @@
gap: 32px;
align-items: flex-start;
padding: 140px 40px 60px;
// Reserved by the detail shell while its sticky Back control shows.
padding-inline-start: max(40px, var(--detail-back-lane, 0px));
max-width: var(--content-max-width);
margin: 0 auto;
box-sizing: border-box;
@@ -511,6 +513,7 @@
&__content {
padding: 60px 20px 40px;
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
gap: 16px;
}
}
@@ -552,6 +555,7 @@
flex-direction: column;
align-items: stretch;
padding-inline: 16px;
padding-inline-start: max(16px, var(--detail-back-lane, 0px));
}
.poster {
@@ -6,6 +6,8 @@
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 {
@@ -127,6 +129,7 @@
@media (max-width: 800px) {
.about {
padding: 20px 20px 32px;
padding-inline-start: max(20px, var(--detail-back-lane, 0px));
&__poster {
width: 92px;
@@ -13,6 +13,13 @@
--tag-border: rgba(0, 0, 0, 0.12);
--poster-shadow: 0 8px 40px rgba(0, 0, 0, 0.15);
--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%;
@@ -30,6 +37,12 @@
}
}
:host(.shell-host--back) {
--detail-back-lane: calc(
2 * var(--shell-back-inset) + var(--shell-back-size)
);
}
:host-context(.dark-theme) {
--surface-bg: #141414;
--text-primary: #fff;
@@ -43,7 +56,7 @@
.shell__navigation {
// Direct child of the scroll owner: sticky lasts through the entire page.
position: sticky;
top: 16px;
top: var(--shell-back-inset);
height: 0;
z-index: 10;
pointer-events: none;
@@ -51,15 +64,15 @@
.shell__back-button {
position: absolute;
left: 16px;
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: 40px;
height: 40px;
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));
@@ -122,6 +135,7 @@
&--active {
padding: 16px 40px 0;
padding-inline-start: max(40px, var(--detail-back-lane));
animation: shell-fade-in 300ms ease 50ms both;
}
}
@@ -130,6 +144,7 @@
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 40px;
padding-inline-start: max(40px, var(--detail-back-lane));
}
.shell__about {
@@ -143,6 +158,7 @@
max-width: var(--content-max-width);
margin: 0 auto;
padding: 0 40px 40px;
padding-inline-start: max(40px, var(--detail-back-lane));
}
@keyframes shell-fade-in {
@@ -159,14 +175,38 @@
@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;
}
}
@@ -451,3 +451,67 @@ 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,6 +2,7 @@ import { NgTemplateOutlet } from '@angular/common';
import {
afterNextRender,
Component,
DestroyRef,
ElementRef,
Injector,
computed,
@@ -23,6 +24,14 @@ 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.
*
@@ -61,12 +70,15 @@ import {
// 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');
@@ -108,6 +120,7 @@ export class PortalDetailShellComponent {
element.focus({ preventScroll: true });
}
});
this.observeCompactWidth();
let wasWatch = false;
effect(() => {
const watch = this.isWatch();
@@ -241,4 +254,24 @@ 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());
}
}
@@ -4,6 +4,10 @@
.section-header {
display: flex;
// The actions move onto their own row before the title has to wrap. The
// pane is narrower than the window (context panel, Back lane), so a
// viewport breakpoint cannot tell when the two stop fitting side by side.
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
@@ -14,14 +18,16 @@
display: flex;
align-items: center;
gap: 16px;
flex: 1;
flex: 1 1 auto;
}
// Beside the title the header's space-between keeps the actions at the
// end; once they move to their own row they start under the title.
&__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
justify-content: flex-start;
gap: 8px;
}
@@ -117,7 +123,6 @@
@media (max-width: 560px) {
.section-header {
align-items: flex-start;
flex-wrap: wrap;
&__actions {
width: 100%;
@@ -203,6 +203,9 @@
.player-shell__title-row {
flex-direction: column;
// Centered, the nowrap heading took its full text width and spilled
// past both card edges; stretched, the title ellipsizes in the card.
align-items: stretch;
}
.player-shell__actions {