Files
iptvnator/apps/web-e2e/src/mobile-layout.e2e.ts
T
4grayandClaude Fable 5 e86e988e72 feat(ui): turn the phone context panel into an off-canvas drawer (#1332)
* feat(ui): turn the phone context panel into an off-canvas drawer

On ≤640px viewports the workspace context panel (categories, filters,
settings sections, collection filters) no longer stacks above the route
content capped at 30vh — it is a hidden-by-default drawer that slides in
from the left over a backdrop, opened via a new header toggle
(phone-only, CSS-gated) and closed by selection, backdrop tap, Escape,
or any navigation.

State lives in the new WorkspaceShellContextDrawerService provided by
the shell component; panels close it explicitly after selections that
do not navigate (Stalker ITV/radio categories, settings sections,
sources filters, collection filters), since NavigationEnd alone cannot
cover those. Desktop behavior is untouched, including the
ResizableDirective inline width.

Closes the drawer follow-up deferred from #1100 / PR #1326.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): make the phone context drawer modal for keyboard users

Addresses Greptile P1 and Codex P2 review feedback on #1332:

- CdkTrapFocus on the sidebar captures focus into the drawer on open and
  contains it while the drawer is modal; the shell restores focus to the
  header toggle on close, since the closed drawer is visibility: hidden
  and focus left inside it would silently drop to <body>.
- The drawer service closes the drawer when the viewport leaves the
  phone breakpoint (matchMedia), so the trap can never hold the in-flow
  desktop sidebar after a resize.
- The toggle's tooltip and aria-label are now variant-aware — categories
  on portal routes, filters on sources/collection routes, settings
  sections on the settings route — instead of a fixed 'Categories &
  filters' that misdescribed two of the three; the two generic i18n keys
  are replaced by six variant keys across all 19 locales.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): remove background content from the a11y tree while the drawer is open

Round-2 review feedback on #1332 (Greptile P1, Codex P2):

- The rail, header, route content and playback footer are marked inert
  while the phone drawer is open — CdkTrapFocus constrains Tab focus,
  but a screen reader's virtual cursor could still reach and activate
  the visually obscured controls behind the backdrop.
- The drawer panel itself is the trap's initial focus target
  (tabindex=-1 + cdkFocusInitial), so focus capture still works when a
  category list is loading, empty, or failed and renders no focusable
  rows.
- Focus restore on close is deferred one tick: the toggle lives in the
  inert header, and focus() on a still-inert element is silently
  ignored.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(playback): gate global shortcuts and Escape behind the open phone drawer

Round-3 review feedback on #1332 (Codex P2s):

- The shell consumes Escape while the drawer is open: downstream Escape
  consumers (the portal detail shell's inline player close, the shared
  controls shortcuts) check defaultPrevented, so one keypress no longer
  closes both the drawer and the obscured playback surface.
- inert does not silence document-level keydown listeners, so players
  opt out themselves while inside an inert region: ControlsShortcuts
  gains an optional hostElement handler and ignores every shortcut
  (including Escape) when that host has an inert ancestor, and the radio
  audio player applies the same check to its volume/mute keys.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): close control, Cmd+F gate, and Embedded MPV inert guard for the drawer

Round-4 review feedback on #1332 (Greptile P1, Codex P2s):

- The drawer carries its own phone-only close button: touch
  screen-reader users have no hardware Escape and cannot reach the inert
  header toggle or the aria-hidden backdrop, so the trapped surface must
  offer dismissal itself — even when a category list is loading or
  empty and renders no actionable entries.
- Ctrl/Cmd+F no longer opens global search while the drawer is modal;
  the shortcut would have navigated and focused an input inside the
  inert header.
- EmbeddedMpvShortcuts (native-view legacy dock) gains the same
  hostElement/inert-ancestor guard as the shared controls shortcuts, so
  the obscured player cannot react to Space/arrows/M/Escape behind the
  drawer.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): round-5 drawer feedback + update phone-layout e2e for the drawer

Merges master (#1333 landed mobile-layout.e2e.ts pinning the #1326
stacked-panel behavior this PR replaces) and updates that spec to pin
the drawer contract instead: panel hidden by default with full-width
content, header toggle opens it over a backdrop, category selection and
backdrop tap close it. Verified locally on Chromium, Firefox and WebKit
(12/12). The spec's getByTestId calls needed plain [data-test-id=...]
locators — the web-e2e Playwright config never mapped testIdAttribute.

Also addresses Codex round-5 P2s:
- Focus restore now reports whether the toggle received focus; when a
  drawer selection navigated to a route without a context panel (toggle
  gone), focus falls back to the route content instead of dropping to
  <body>.
- The Xtream and Stalker live layouts' Ctrl/Cmd+B sidebar shortcut opts
  out while their host sits inside an inert region, matching the other
  document-level listeners.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): suppress command palette and shortcuts dialog behind the open drawer

Greptile round-6 finding on #1332: the document-level Ctrl/Cmd+K
handler in WorkspaceShellFacade and the '?' help-key handler in
WorkspaceKeyboardShortcutsService still opened their dialogs while the
phone context drawer was modal, stacking a second focus-trapped surface
on top of it. Both now check the drawer service (injected optionally,
same shell-component providers) and stay quiet while it is open, like
the Ctrl/Cmd+F global-search gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): round-7 drawer feedback — Cmd+R gate and native MPV surface hiding

Addresses the two Codex round-7 P2s on #1332:

- WorkspaceShellContextDrawerService moves to @iptvnator/workspace/shell/util
  and becomes root-provided, so AppComponent's document-level Ctrl/Cmd+R
  global-recent shortcut can observe the modal drawer without pulling the
  lazy shell chunk into the eager bundle. Cmd+R is now suppressed while
  the drawer is open, like Cmd+F/Cmd+K/'?'.
- The shell registers the open drawer with a new
  EmbeddedMpvOverlayVisibilityService.acquireExternalModalSurface() API:
  the native-view video surface is composited outside DOM stacking and
  would paint straight over the drawer regardless of z-index. The service
  treats registered external modal surfaces exactly like open Material
  dialogs.
- The service's recompute no longer reads overlayActive back before
  setting it: signals already skip notification on equal values, and that
  hidden read registered overlayActive as a dependency of any reactive
  context calling into the service — the shell's acquire/release effect
  looped forever on exactly that (caught by a live browser probe; the
  unit suite mocked the service). The effect also wraps the acquire in
  untracked() for caller-side hygiene.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): expose the phone drawer as a named modal dialog

Round-8 review feedback on #1332 (Codex P2s):

- While open, the drawer carries role=dialog, aria-modal=true, and a
  variant-appropriate accessible name (categories / filters / settings
  sections) — assistive technology now hears that a named modal surface
  opened instead of an unnamed complementary landmark. Closed (and the
  always-visible desktop sidebar) stays a plain landmark.
- The UI-guidelines drawer section no longer claims the drawer service
  is component-provided; it is root-provided from workspace/shell/util
  since the round-7 move, and the stale claim could have led a future
  change to re-scope it and silently break the AppComponent shortcut
  gate and the Embedded MPV overlay observer. Matching code comments
  updated everywhere.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(ui): round-9 — gate M3U player keys behind the drawer, raise drawer stacking

Greptile round-9 P1 + Codex round-9 P2 on #1332:

- The M3U video player's document-level digit-key channel switching and
  Ctrl/Cmd+B sidebar toggle now apply the same inert-ancestor guard as
  every other routed-content key listener. A codebase sweep confirms
  this closes the class: every document-level key listener on routed
  content is now either gated by the shell (Escape, Cmd+F/K/R, '?') or
  opts out via closest('[inert]'); the guidelines now require the guard
  for any new listener.
- The drawer moves from z-index 99/98 to 951/950: above the settings
  action bar (100) and the root EPG/update panels (900/901), which
  inert removes from interaction but not from paint order — below the
  CDK overlay container (1000), since dialogs opened from inside the
  drawer (Manage categories) must stack on top of it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-02 09:11:11 +02:00

245 lines
10 KiB
TypeScript

import type { Locator, Page } from '@playwright/test';
import { expect, test } from './fixtures';
import {
addXtreamPortal,
interceptPwaProviderRequests,
resetPwaMockServers,
} from './sources-pwa.helpers';
/**
* Mobile Layout Smoke Tests
*
* PR #1326 made the workspace usable on phone-sized screens (issue #1100);
* the follow-up drawer PR turned the phone context panel into an off-canvas
* drawer. These tests pin the invariants that regressed before:
*
* 1. No horizontal overflow — document.documentElement.scrollWidth stays
* within the viewport on dashboard, Xtream VOD/live, and settings.
* 2. The workspace rail links render inside the 52px top bar instead of
* stacking downwards over the header.
* 3. On a portal route the context panel is an off-canvas drawer: hidden
* by default so the content keeps the full viewport width, opened from
* the header toggle (winning over the persisted desktop inline width),
* and closed again by picking a category or tapping the backdrop.
* 4. The settings section list scrolls instead of painting over the
* Back footer — now inside the open drawer.
* 5. On a 640x360 landscape phone the live route keeps the channel
* sidebar at least 72px tall and the player container inside the
* viewport.
*
* Conventions: docs/architecture/iptvnator-ui-guidelines.md, "Phone Layout"
* (640px breakpoint, resizable rails stack full-width via `!important`).
*
* Tag: @mobile — run only this spec with:
* pnpm nx run web-e2e:e2e-ci--src/mobile-layout.e2e.ts
*/
const PHONE = { width: 375, height: 812 };
const LANDSCAPE_PHONE = { width: 640, height: 360 };
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
async function expectNoHorizontalOverflow(
page: Page,
viewportWidth: number
): Promise<void> {
await expect
.poll(() => page.evaluate(() => document.documentElement.scrollWidth))
.toBeLessThanOrEqual(viewportWidth);
}
async function boxOf(
locator: Locator
): Promise<{ x: number; y: number; width: number; height: number }> {
const box = await locator.boundingBox();
expect(box, 'expected element to have a bounding box').not.toBeNull();
return box as NonNullable<typeof box>;
}
/**
* Before #1326 the nested `.rail-links` list kept `flex-direction: column`
* inside the horizontal phone bar, so links 2..n were laid out below the
* 52px row and overlapped the header. Horizontal overflow inside the bar
* is fine — the bar scrolls sideways by design — so only y is asserted.
*/
async function expectRailLinksInsideTopBar(page: Page): Promise<void> {
const rail = page.locator('.app-rail');
await expect(rail).toBeVisible();
const railBox = await boxOf(rail);
// The shell grid gives the rail row 52px on phones.
expect(railBox.height).toBeLessThanOrEqual(60);
await expect(page.locator('.rail-links').first()).toHaveCSS(
'flex-direction',
'row'
);
const links = page.locator('.app-rail a:visible');
const count = await links.count();
expect(count).toBeGreaterThan(0);
for (let i = 0; i < count; i++) {
const linkBox = await boxOf(links.nth(i));
expect(linkBox.y).toBeGreaterThanOrEqual(railBox.y - 1);
expect(linkBox.y + linkBox.height).toBeLessThanOrEqual(
railBox.y + railBox.height + 1
);
}
}
// ---------------------------------------------------------------------------
// Portrait phone — no imported sources needed, app boots at phone size
// ---------------------------------------------------------------------------
test.describe('portrait phone 375x812', () => {
test.use({ viewport: PHONE });
test('@mobile dashboard fits the viewport and keeps rail links in the top bar', async ({
page,
}) => {
await page.goto('/workspace/dashboard');
await expect(page.locator('.app-rail')).toBeVisible();
await expectNoHorizontalOverflow(page, PHONE.width);
await expectRailLinksInsideTopBar(page);
});
test('@mobile settings drawer opens from the header toggle and keeps the section list clear of the Back footer', async ({
page,
}) => {
await page.goto('/workspace/settings');
// The phone context panel is an off-canvas drawer: hidden until the
// header toggle opens it, so the settings content owns the pane.
const panel = page.locator('.context-panel--settings');
await expect(panel).toBeHidden();
await page.locator('[data-test-id="context-drawer-toggle"]').click();
await expect(panel).toBeVisible();
// Narrower than the viewport so the backdrop stays tappable, and
// wider than the persisted desktop inline width would leave it.
const panelBox = await boxOf(panel);
expect(panelBox.width).toBeGreaterThanOrEqual(300);
expect(panelBox.width).toBeLessThanOrEqual(PHONE.width - 20);
const footer = panel.locator('.settings-panel-footer');
await expect(footer.locator('.settings-back-button')).toBeVisible();
// Before #1326 the section list kept its full content height and
// painted over the footer whenever the panel was shorter than its
// sections; now the list scrolls and ends above the footer.
const listBox = await boxOf(panel.locator('.settings-sections-list'));
const footerBox = await boxOf(footer);
expect(listBox.y + listBox.height).toBeLessThanOrEqual(
footerBox.y + 1
);
// Tapping the backdrop (right of the drawer) closes it.
await page
.locator('[data-test-id="context-drawer-backdrop"]')
.click({ position: { x: PHONE.width - 10, y: 400 } });
await expect(panel).toBeHidden();
await expectNoHorizontalOverflow(page, PHONE.width);
});
});
// ---------------------------------------------------------------------------
// Xtream portal routes — the portal is imported at the default desktop
// viewport (which persists desktop rail widths, the exact regression
// scenario from #1100), then the window shrinks to phone size.
// ---------------------------------------------------------------------------
test.describe('xtream portal routes on a phone', () => {
test.beforeEach(async ({ page, request }) => {
await resetPwaMockServers(request);
await interceptPwaProviderRequests(page);
await page.goto('/');
await addXtreamPortal(page, 'Mobile Layout Portal');
});
test('@mobile @xtream vod route keeps the context panel in a drawer behind the header toggle', async ({
page,
}) => {
await page.setViewportSize(PHONE);
// Hidden by default — the content owns the full pane. This is the
// successor to the #1326 stacked layout, which left the content
// only the leftover under a 30vh panel.
const panel = page.locator('.context-panel');
await expect(panel).toBeHidden();
const content = page.locator('main.workspace-content');
const contentWidth = await content.evaluate((el) => el.clientWidth);
expect(contentWidth).toBeGreaterThanOrEqual(PHONE.width - 20);
// The header toggle slides the drawer in. The persisted desktop
// width is written as an inline style; the drawer rule must win
// with `width: 100% !important` of its ~320px surface, otherwise
// the panel keeps its desktop width fraction.
await page.locator('[data-test-id="context-drawer-toggle"]').click();
await expect(panel).toBeVisible();
await expect
.poll(async () => (await panel.boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(300);
// Picking a category both filters the route and closes the drawer.
const firstCategory = page
.locator('.context-panel .category-item')
.first();
await expect(firstCategory).toBeVisible();
await firstCategory.click();
await expect(panel).toBeHidden();
await expectNoHorizontalOverflow(page, PHONE.width);
await expectRailLinksInsideTopBar(page);
});
test('@mobile @xtream landscape live route keeps the channel list and the player inside the viewport', async ({
page,
}) => {
// Select a category at desktop width so the channel sidebar renders
// (`showLiveChannelSidebar` requires a selected category). On the
// live root the click updates store state without navigating, so the
// sidebar appearing is the completion signal — not a URL change.
await page.goto(page.url().replace(/\/vod.*$/, '/live'));
const firstCategory = page
.locator('.context-panel .category-item')
.first();
await expect(firstCategory).toBeVisible();
await firstCategory.click();
const sidebar = page.locator('app-live-stream-layout .sidebar');
await expect(sidebar).toBeVisible();
await page.setViewportSize(LANDSCAPE_PHONE);
// The sidebar stacks at full width above the player.
await expect
.poll(async () => (await sidebar.boundingBox())?.width ?? 0)
.toBeGreaterThanOrEqual(LANDSCAPE_PHONE.width - 2);
// Before the follow-up fix in #1326 the player's 240px floor drove
// the still-expanded sidebar to zero height on a 640x360 screen,
// leaving no way to pick another channel.
const sidebarBox = await boxOf(sidebar);
expect(sidebarBox.height).toBeGreaterThanOrEqual(71);
// ...and pushed the layout past the viewport. The player container
// has to end inside it.
const contentBox = await boxOf(
page.locator('app-live-stream-layout .content-container')
);
expect(contentBox.y + contentBox.height).toBeLessThanOrEqual(
LANDSCAPE_PHONE.height + 1
);
expect(contentBox.width).toBeLessThanOrEqual(LANDSCAPE_PHONE.width);
await expectNoHorizontalOverflow(page, LANDSCAPE_PHONE.width);
});
});