From 9325753d2dd63d09fc24dba6d26dd7281581e9d2 Mon Sep 17 00:00:00 2001 From: 4gray Date: Wed, 30 Sep 2026 20:18:02 +0200 Subject: [PATCH] fix(epg): stack programme dialog actions on phones MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Below the 640px phone breakpoint the viewport caps the programme dialog, and a long translated primary label ("Regarder depuis le début") no longer fit beside Close. The footer had no wrap, and the dialog hides overflow, so the label was clipped. - At the phone breakpoint, the archive tools and the footer now stack one full-width button per row, in DOM order. - Buttons grow to fit their label, so a long label wraps inside its button instead of being clipped. - On desktop the footer can wrap again as a last resort. The new Electron test opens a past programme in French at a 360px viewport and measures both rows. It fails against the previous stylesheet (the footer buttons are 44px narrower than the row). Co-Authored-By: Claude Opus 5.5 --- .../src/epg-timeline-interaction.e2e.ts | 108 ++++++++++++++++++ docs/architecture/iptvnator-ui-guidelines.md | 4 +- .../epg-item-description.component.scss | 23 +++- 3 files changed, 133 insertions(+), 2 deletions(-) diff --git a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts index d0ec1e0a9..2762bc823 100644 --- a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts @@ -21,6 +21,8 @@ import { } from './portal-mock-fixtures'; import type { Locator, Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { applyTheme, expectTextContrast, @@ -315,6 +317,112 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an } }); +test('@epg @xtream @electron stacks the programme dialog actions on a phone', async ({ + dataDir, + request, +}) => { + // French carries the longest primary label ("watch from start"). + const fr = JSON.parse( + readFileSync( + join(__dirname, '../../web/src/assets/i18n/fr.json'), + 'utf8' + ) + ) as { + WORKSPACE: { SHELL: { RAIL_LIVE: string } }; + EPG: { + PROGRAM_DIALOG: { SHOW_PROGRAM_DETAILS: string }; + TIMELINE: { WATCH_FROM_START: string }; + }; + }; + await resetMockServers(request, ['xtream']); + const fixture = await fetchXtreamEpgFixture(request, epgCredentials); + const app = await launchElectronApp(dataDir, { env: { TZ: 'UTC' } }); + + try { + await app.mainWindow.route('https://test-streams.mux.dev/**', () => { + // Keep the external demo request pending; guide data is local. + }); + await addXtreamPortal(app.mainWindow, { + name: 'Xtream Phone Dialog', + username: epgCredentials.username, + password: epgCredentials.password, + }); + await waitForXtreamWorkspaceReady(app.mainWindow); + await openSettings(app.mainWindow); + await app.mainWindow.getByTestId('select-language').click(); + await app.mainWindow.locator('mat-option[data-test-id="fr"]').click(); + await openSettingsSection(app.mainWindow, 'epg'); + await app.mainWindow.getByTestId('epg-view-mode-list').click(); + await saveSettings(app.mainWindow); + + await openWorkspaceSection( + app.mainWindow, + fr.WORKSPACE.SHELL.RAIL_LIVE + ); + await clickCategoryByNameExact(app.mainWindow, fixture.categoryName); + await channelItemByTitle(app.mainWindow, fixture.stream.name ?? '') + .first() + .click(); + const guide = app.mainWindow.locator('app-epg-list-view'); + await expect(guide.locator('[data-when="past"]').first()).toBeVisible(); + + // The window cannot shrink below its desktop minimum, so emulate a + // phone viewport below the 640px breakpoint. + await app.mainWindow.setViewportSize({ width: 360, height: 800 }); + await guide + .locator('[data-when="past"]') + .first() + .getByRole('button', { + name: fr.EPG.PROGRAM_DIALOG.SHOW_PROGRAM_DETAILS, + }) + .click(); + const dialog = app.mainWindow.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('.epg-dialog__btn--primary')).toContainText( + fr.EPG.TIMELINE.WATCH_FROM_START + ); + + // Every action row stacks one full-width button per row in DOM + // order, and no label spills out of its button. + const rows = await dialog + .locator('.epg-dialog__tools, .epg-dialog__actions') + .evaluateAll((containers) => + containers.map((container) => { + const buttons = Array.from( + container.querySelectorAll('button') + ); + return { + width: (container as HTMLElement).clientWidth, + buttons: buttons.map((button) => ({ + top: button.offsetTop, + bottom: button.offsetTop + button.offsetHeight, + width: button.offsetWidth, + overflows: + button.scrollWidth > button.clientWidth + 1, + })), + }; + }) + ); + expect(rows).toHaveLength(2); + for (const row of rows) { + expect(row.buttons.length).toBeGreaterThan(1); + row.buttons.forEach((button, index) => { + expect(Math.abs(button.width - row.width)).toBeLessThanOrEqual( + 1 + ); + expect(button.overflows).toBe(false); + if (index > 0) { + expect(button.top).toBeGreaterThanOrEqual( + row.buttons[index - 1].bottom + ); + } + }); + } + } finally { + await closeElectronApp(app); + } +}); + test('@epg @stalker @theme @electron applies live themes to the shared Stalker guide', async ({ dataDir, request, diff --git a/docs/architecture/iptvnator-ui-guidelines.md b/docs/architecture/iptvnator-ui-guidelines.md index 698736f28..0305755da 100644 --- a/docs/architecture/iptvnator-ui-guidelines.md +++ b/docs/architecture/iptvnator-ui-guidelines.md @@ -671,7 +671,9 @@ Settings use the same system but are flatter than content-heavy views. button per row, in DOM order (dismiss on top, primary at the bottom). The settings unsaved-changes dialog is the reference; `settings-unsaved-dialog-layout.e2e.ts` in `web-e2e` measures it in six - locales. + locales. The programme dialog stacks its archive tools and footer the same + way, letting a long label wrap inside its button; the Electron + `epg-timeline-interaction.e2e.ts` measures it in French at 360px. - **Width.** A dialog with several openers is opened through one helper that owns its `MatDialogConfig`, so its width never depends on the entry point. `EpgProgrammeDialogService` opens the programme dialog at 540px from the diff --git a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss index e51181a24..c24280448 100644 --- a/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss +++ b/libs/ui/epg/src/lib/epg-item-description/epg-item-description.component.scss @@ -174,9 +174,11 @@ color: $text-tertiary; } -/* Dismiss first, primary last, both on the inline end. */ +/* Dismiss first, primary last, both on the inline end. Wrapping is only a + last resort; both buttons fit one row at the 540px width. */ .epg-dialog__actions { display: flex; + flex-wrap: wrap; justify-content: flex-end; align-items: center; gap: 10px; @@ -222,6 +224,25 @@ } } +/* Phone breakpoint: the viewport caps the dialog, so actions stack one per + row at full width, in DOM order (dismiss above the primary). A long + translated label wraps inside its button instead of being clipped. */ +@media (max-width: 640px) { + .epg-dialog__tools, + .epg-dialog__actions { + flex-direction: column; + align-items: stretch; + } + + .epg-dialog__btn { + justify-content: center; + height: auto; + min-height: 40px; + padding-block: 8px; + text-align: center; + } +} + /* Let the dialog provide its own surface, border and shadow. Scoped to the panel class from EPG_PROGRAMME_DIALOG_CONFIG so no other dialog loses its surface while this one is open. */