mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
fix(detail): keep the Back button from covering detail content (#1763)
This commit is contained in:
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 {
|
||||
|
||||
Reference in new issue
Block a user