mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 17:06:15 -08:00
fix(detail): let the pane width choose between the Back lane and the bar
Review follow-up. The bar fallback keyed on the 640px viewport breakpoint, but beside the context panel a desktop pane can be narrower than a phone: at a 641px window the lane left the player card ~150px wide. The shell now measures its own border box with a ResizeObserver and sets shell-host--compact below 400px, so the bar covers phones and squeezed desktop panes alike, independent of scrollbar width. The Electron E2E now asserts the lane mode at 1280/780px, sweeps the bar at 700/375px (opaque, hit-tests over whatever scrolls beneath it), and checks the season heading stays on one line from 680 to 1600px. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
377b22124b
commit
38b9e4e267
5 files changed
+221
-15
No files matched your search
@@ -18,9 +18,15 @@ import {
|
||||
// workspace rail and category panel leave the detail pane far narrower than
|
||||
// either, and the heading used to wrap beside its actions (two lines at
|
||||
// 1280px, three at 780px) with its first word under the arrow.
|
||||
//
|
||||
// A pane under 400px cannot spare the lane: a 700px window beside the
|
||||
// category panel, or a phone. There the control sits in an opaque sticky bar,
|
||||
// and whatever reaches the arrow's band must be hidden beneath the bar, never
|
||||
// visible beside the arrow.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const widths = [1280, 780];
|
||||
const compactWidths = [700, 375];
|
||||
const playerCorner = 56;
|
||||
|
||||
type Sweep = {
|
||||
@@ -79,6 +85,71 @@ function sweepDetail(shell: Locator): Promise<Sweep> {
|
||||
}, playerCorner);
|
||||
}
|
||||
|
||||
type BarSweep = {
|
||||
background: string;
|
||||
/** Scroll positions where a target reached the bar's band. */
|
||||
beneath: number;
|
||||
/** Points of a target in the bar's band that hit content, not the bar. */
|
||||
exposed: string[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Compact counterpart of {@link sweepDetail}: the bar sits in flow and
|
||||
* content scrolls beneath it, so boxes do intersect. Instead, every sampled
|
||||
* point of the heading or the player's corner that is inside the bar's band
|
||||
* must hit-test to the bar: hidden beneath it, and unreachable by a tap.
|
||||
*/
|
||||
function sweepBar(shell: Locator): Promise<BarSweep> {
|
||||
return shell.evaluate((element, corner) => {
|
||||
const bar = element.querySelector('.shell__navigation');
|
||||
if (!bar) throw new Error('The detail shows no Back control.');
|
||||
const sweep = {
|
||||
background: getComputedStyle(bar).backgroundColor,
|
||||
beneath: 0,
|
||||
exposed: [] as string[],
|
||||
};
|
||||
const max = element.scrollHeight - element.clientHeight;
|
||||
for (let top = 0; ; top = Math.min(max, top + 24)) {
|
||||
element.scrollTop = top;
|
||||
const band = bar.getBoundingClientRect();
|
||||
const title = element
|
||||
.querySelector('.section-title')
|
||||
?.getBoundingClientRect();
|
||||
const player = element
|
||||
.querySelector('app-portal-inline-player')
|
||||
?.getBoundingClientRect();
|
||||
const targets = {
|
||||
title,
|
||||
player: player && {
|
||||
left: player.left,
|
||||
top: player.top,
|
||||
right: player.left + corner,
|
||||
bottom: player.top + corner,
|
||||
},
|
||||
};
|
||||
for (const [name, box] of Object.entries(targets)) {
|
||||
if (!box) continue;
|
||||
const from = Math.max(box.top, band.top);
|
||||
const to = Math.min(box.bottom, band.bottom);
|
||||
if (to <= from) continue;
|
||||
sweep.beneath += 1;
|
||||
const y = (from + to) / 2;
|
||||
for (const x of [box.left + 2, box.right - 2]) {
|
||||
const hit = element.ownerDocument.elementFromPoint(x, y);
|
||||
if (!hit || !bar.contains(hit)) {
|
||||
sweep.exposed.push(
|
||||
`${name} at scrollTop ${top} hits ${hit?.tagName}.${String(hit?.className)}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (top >= max) break;
|
||||
}
|
||||
element.scrollTop = 0;
|
||||
return sweep;
|
||||
}, playerCorner);
|
||||
}
|
||||
|
||||
/** Line boxes of the heading's text; 1 means it did not wrap. */
|
||||
function headingLineCount(page: Page): Promise<number> {
|
||||
return page.locator('.section-title').evaluate((heading) => {
|
||||
@@ -92,15 +163,17 @@ function headingLineCount(page: Page): Promise<number> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Lets the browse↔watch morph and the player's fade-in settle. Bounded, so a
|
||||
* paused animation elsewhere in the player cannot stall the test.
|
||||
* Lets the browse↔watch morph, the player's fade-in and the workspace's own
|
||||
* transitions settle — crossing into the phone layout slides the category
|
||||
* drawer out over the page for 200ms. Bounded, so a paused animation
|
||||
* elsewhere cannot stall the test.
|
||||
*/
|
||||
async function settle(shell: Locator): Promise<void> {
|
||||
await shell.evaluate((element) =>
|
||||
Promise.race([
|
||||
Promise.all(
|
||||
element
|
||||
.getAnimations({ subtree: true })
|
||||
element.ownerDocument
|
||||
.getAnimations()
|
||||
.filter(
|
||||
(animation) =>
|
||||
animation.effect?.getTiming().iterations !==
|
||||
@@ -122,6 +195,11 @@ async function expectBackClearOfContent(
|
||||
const shell = page.locator('app-portal-detail-shell');
|
||||
for (const width of widths) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
// Fail on the mode first: a lane assertion against the bar would
|
||||
// only report confusing intersections.
|
||||
await expect(shell, `${state} at ${width}px`).not.toHaveClass(
|
||||
/shell-host--compact/
|
||||
);
|
||||
await settle(shell);
|
||||
const sweep = await sweepDetail(shell);
|
||||
|
||||
@@ -137,6 +215,35 @@ async function expectBackClearOfContent(
|
||||
}
|
||||
expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1);
|
||||
}
|
||||
|
||||
for (const width of compactWidths) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
await expect(shell, `${state} at ${width}px`).toHaveClass(
|
||||
/shell-host--compact/
|
||||
);
|
||||
await settle(shell);
|
||||
const sweep = await sweepBar(shell);
|
||||
|
||||
expect(sweep.exposed, `${state} at ${width}px`).toEqual([]);
|
||||
expect(sweep.beneath, `${state} at ${width}px`).toBeGreaterThan(0);
|
||||
// Opaque, or what scrolls beneath would show through.
|
||||
expect(sweep.background, `${state} at ${width}px`).toMatch(/^rgb\(/);
|
||||
expect(await headingLineCount(page), `${state} at ${width}px`).toBe(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The actions move onto their own row before the heading wraps, at every
|
||||
* pane width that can hold the heading at all — including the widths where
|
||||
* the lane gives way to the bar.
|
||||
*/
|
||||
async function expectHeadingOnOneLine(page: Page): Promise<void> {
|
||||
const wrapped: number[] = [];
|
||||
for (let width = 680; width <= 1600; width += 20) {
|
||||
await page.setViewportSize({ width, height: 800 });
|
||||
if ((await headingLineCount(page)) !== 1) wrapped.push(width);
|
||||
}
|
||||
expect(wrapped).toEqual([]);
|
||||
}
|
||||
|
||||
test.describe('Portal detail Back lane', () => {
|
||||
@@ -167,6 +274,7 @@ test.describe('Portal detail Back lane', () => {
|
||||
await expect(episodes.first()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
await expectBackClearOfContent(page, 'browse');
|
||||
await expectHeadingOnOneLine(page);
|
||||
|
||||
await page.setViewportSize({ width: widths[0], height: 800 });
|
||||
await episodes.first().click();
|
||||
|
||||
@@ -37,10 +37,14 @@ position puts the arrow over a heading or the player. The shell derives the lane
|
||||
from the button's geometry and publishes it as `--detail-back-lane`, which
|
||||
`content-hero` and `content-about` read too; hosts with `backAvailable=false`
|
||||
keep their own inset. The lane is horizontal because watch state is short of
|
||||
height, not width. The phone breakpoint (640 px) is the exception: a 72 px column
|
||||
would clip the player's controls, so there the wrapper becomes an in-flow 56 px
|
||||
sticky bar on `--app-header-bg` that content scrolls beneath. Electron E2E
|
||||
(`detail-back-lane.e2e.ts`) sweeps both states at 1280 and 780 px.
|
||||
height, not width. A pane narrower than 400 px is the exception — a phone, or a
|
||||
desktop pane beside the context panel — because a 72 px column there would clip
|
||||
the player's controls. The pane decides, not the viewport: a `ResizeObserver`
|
||||
on the shell's border box (scrollbar-independent) sets `shell-host--compact`,
|
||||
and the wrapper becomes an in-flow 56 px sticky bar on `--app-header-bg` that
|
||||
content scrolls beneath and that takes the taps over it. Electron E2E
|
||||
(`detail-back-lane.e2e.ts`) sweeps both states with the lane at 1280 and
|
||||
780 px and with the bar at 700 and 375 px.
|
||||
|
||||
The sticky control is route-level Back in both states: it emits `backClicked`
|
||||
whether or not inline playback is active, so the arrow keeps one meaning and
|
||||
|
||||
@@ -189,13 +189,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// A phone cannot spare a 72px column: the player's controls would clip and
|
||||
// the hero actions would wrap. There the control takes a row instead — an
|
||||
// opaque sticky bar in flow that content scrolls beneath, never alongside.
|
||||
@media (max-width: 640px) {
|
||||
:host(.shell-host--back) {
|
||||
--detail-back-lane: 0px;
|
||||
}
|
||||
// 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;
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
@@ -69,6 +78,7 @@ import {
|
||||
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');
|
||||
|
||||
@@ -110,6 +120,7 @@ export class PortalDetailShellComponent {
|
||||
element.focus({ preventScroll: true });
|
||||
}
|
||||
});
|
||||
this.observeCompactWidth();
|
||||
let wasWatch = false;
|
||||
effect(() => {
|
||||
const watch = this.isWatch();
|
||||
@@ -243,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());
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user