From cb317bad4c726705e0348943d3910ce6f938c4eb Mon Sep 17 00:00:00 2001 From: 4gray <4gray@users.noreply.github.com> Date: Wed, 30 Sep 2026 21:54:09 +0200 Subject: [PATCH] fix(ui): keep dialog action rows on one line (#1762) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(ui): keep dialog action rows on one line Settings "Unsaved changes" dialog: - Cancel / Discard / Save replace the phrase labels in all 19 locales, and the dismiss now comes first; the shared CANCEL key replaces the unused UNSAVED_DIALOG_STAY. - At the 640px phone breakpoint the actions stack one per row, full width, in DOM order. EPG programme dialog: - mat-dialog-title gives the dialog an accessible name. - The footer Close is the only dismiss; it comes first and the primary action last. - The archive copy/download tools move under their notice, so the footer stays on one row. - Channel rows now open it through EpgProgrammeDialogService, which owns the 540px config and a panel class scoping the surface overrides. Adds a web-e2e layout spec (en, de, ru, fr, hu, ar on one row; de and ru stacked on a phone), extends the Electron EPG spec to all three openers, and documents the dialog contract in the UI guidelines. Co-Authored-By: Claude Opus 5.5 * fix(epg): stack programme dialog actions on phones 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 --------- Co-authored-by: Claude Opus 5.5 --- .changes/ui-dialog-action-rows.md | 9 + .../src/epg-timeline-interaction.e2e.ts | 194 ++++++++++++++++- .../src/settings-unsaved-dialog-layout.e2e.ts | 200 ++++++++++++++++++ ...s-unsaved-changes-dialog.component.spec.ts | 78 +++++++ ...ttings-unsaved-changes-dialog.component.ts | 38 +++- apps/web/src/assets/i18n/ar.json | 5 +- apps/web/src/assets/i18n/ary.json | 5 +- apps/web/src/assets/i18n/by.json | 5 +- apps/web/src/assets/i18n/de.json | 5 +- apps/web/src/assets/i18n/el.json | 5 +- apps/web/src/assets/i18n/en.json | 5 +- apps/web/src/assets/i18n/es.json | 5 +- apps/web/src/assets/i18n/fr.json | 5 +- apps/web/src/assets/i18n/hu.json | 5 +- apps/web/src/assets/i18n/it.json | 5 +- apps/web/src/assets/i18n/ja.json | 5 +- apps/web/src/assets/i18n/ko.json | 5 +- apps/web/src/assets/i18n/nl.json | 5 +- apps/web/src/assets/i18n/pl.json | 5 +- apps/web/src/assets/i18n/pt.json | 5 +- apps/web/src/assets/i18n/ru.json | 5 +- apps/web/src/assets/i18n/tr.json | 5 +- apps/web/src/assets/i18n/zh.json | 5 +- apps/web/src/assets/i18n/zhtw.json | 5 +- docs/architecture/iptvnator-ui-guidelines.md | 28 +++ .../channel-list-item.component.spec.ts | 43 +++- .../channel-list-item.component.ts | 12 +- .../epg-item-description.component.html | 106 ++++++---- .../epg-item-description.component.scss | 91 ++++---- .../lib/epg-programme-dialog.service.spec.ts | 122 +++++++++++ .../src/lib/epg-programme-dialog.service.ts | 24 ++- 31 files changed, 853 insertions(+), 187 deletions(-) create mode 100644 .changes/ui-dialog-action-rows.md create mode 100644 apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts create mode 100644 apps/web/src/app/settings/settings-unsaved-changes-dialog.component.spec.ts create mode 100644 libs/ui/epg/src/lib/epg-programme-dialog.service.spec.ts diff --git a/.changes/ui-dialog-action-rows.md b/.changes/ui-dialog-action-rows.md new file mode 100644 index 000000000..87e1e5d5d --- /dev/null +++ b/.changes/ui-dialog-action-rows.md @@ -0,0 +1,9 @@ +--- +type: fix +area: ui +--- + +The "Unsaved changes" dialog in Settings now uses short Cancel / Discard / Save +buttons that stay on one row in every language, and stack neatly on phones. The +programme details dialog has one Close button, keeps its main action on the +right and opens at the same width from every screen. 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 034825f12..2762bc823 100644 --- a/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts +++ b/apps/electron-backend-e2e/src/epg-timeline-interaction.e2e.ts @@ -20,6 +20,9 @@ import { fetchStalkerCategoryFixture, } from './portal-mock-fixtures'; +import type { Locator, Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; import { applyTheme, expectTextContrast, @@ -32,6 +35,42 @@ const epgCredentials = { password: 'epg', }; +/** + * Every opener shares one dialog config: the programme dialog is named by its + * `mat-dialog-title` and opens in the same 540px pane wherever it starts. + */ +async function expectProgrammeDialog( + page: Page, + title?: string +): Promise { + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + const heading = dialog.locator('.epg-dialog__title'); + if (title) { + await expect(heading).toHaveText(title); + } + await expect(dialog).toHaveAccessibleName( + (await heading.innerText()).trim() + ); + await expect + .poll(() => + dialog.evaluate( + (element) => + element.closest('.cdk-overlay-pane') + ?.offsetWidth + ) + ) + .toBe(540); + return dialog; +} + +async function closeProgrammeDialog(page: Page, dialog: Locator) { + // The footer Close is the dialog's only visible dismiss. + await expect(dialog.getByRole('button', { name: 'Close' })).toHaveCount(1); + await dialog.locator('.epg-dialog__close').click(); + await page.waitForSelector('.epg-dialog', { state: 'detached' }); +} + test('@epg @xtream @electron opens the programme dialog from a timeline block and reacts to zoom', async ({ dataDir, request, @@ -172,17 +211,30 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an // programme-details dialog with the programme metadata. await nowBlock.locator('.epg-timeline__info').click(); - const dialog = app.mainWindow.locator('.epg-dialog'); - await expect(dialog).toBeVisible(); - await expect(dialog.locator('.epg-dialog__title')).toHaveText( + const dialog = await expectProgrammeDialog( + app.mainWindow, currentProgram.title ); - // An on-air programme offers "watch live" as the primary action. - await expect(dialog.locator('.epg-dialog__btn--primary')).toBeVisible(); + // An on-air programme offers "watch live" as the primary action, last + // in the footer and to the right of the dismiss. + const footer = dialog.locator('.epg-dialog__actions button'); + await expect(footer).toHaveCount(2); + await expect(footer.first()).toHaveClass(/epg-dialog__close/); + await expect(footer.last()).toHaveClass(/epg-dialog__btn--primary/); + // Layout offsets, not bounding boxes: the dialog may still be scaling + // in, which transforms client rects. + const [closeBox, primaryBox] = await footer.evaluateAll((buttons) => + buttons.map((button) => ({ + left: (button as HTMLElement).offsetLeft, + top: (button as HTMLElement).offsetTop, + })) + ); + expect(primaryBox.left).toBeGreaterThan(closeBox.left); + expect(primaryBox.top).toBe(closeBox.top); for (const theme of ['light', 'dark', 'light'] as const) { await applyTheme(app.mainWindow, theme); - await expectThemeSurface(dialog, theme); + await expectThemeSurface(dialog.locator('.epg-dialog'), theme); await expectTextContrast(dialog.locator('.epg-dialog__title')); await expectTextContrast(dialog.locator('.epg-dialog__desc')); await expectTextContrast(dialog.locator('.epg-dialog__close'), 3); @@ -190,10 +242,15 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an await app.mainWindow.screenshot({ path: test.info().outputPath('epg-light.png'), }); - await dialog.locator('.epg-dialog__close').click(); - await app.mainWindow.waitForSelector('.epg-dialog', { - state: 'detached', - }); + await closeProgrammeDialog(app.mainWindow, dialog); + + // The channel row's info button opens the same dialog. + await channelRow.locator('.program-info-button').click(); + await closeProgrammeDialog( + app.mainWindow, + await expectProgrammeDialog(app.mainWindow) + ); + await openSettings(app.mainWindow); await openSettingsSection(app.mainWindow, 'epg'); await app.mainWindow.getByTestId('epg-view-mode-list').click(); @@ -218,6 +275,17 @@ test('@epg @xtream @electron opens the programme dialog from a timeline block an guide.locator('[data-when="now"] .desc').first() ); } + // The list view's info button opens the same dialog. + await guide + .locator('[data-when="now"]') + .first() + .getByRole('button', { name: 'Show details about this program' }) + .click(); + await closeProgrammeDialog( + app.mainWindow, + await expectProgrammeDialog(app.mainWindow) + ); + // Keep a fresh channel's EPG IPC pending so the real list loading // template stays mounted through both theme changes. await app.electronApp.evaluate(({ ipcMain }) => { @@ -249,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/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts b/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts new file mode 100644 index 000000000..c8fbdae50 --- /dev/null +++ b/apps/web-e2e/src/settings-unsaved-dialog-layout.e2e.ts @@ -0,0 +1,200 @@ +import type { Locator, Page } from '@playwright/test'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { expect, test } from './fixtures'; + +/** + * The audit found the unsaved-changes actions wrapping in most locales. These + * are the long-label locales it measured, plus Arabic for the RTL row. + */ +const LOCALES = ['en', 'de', 'ru', 'fr', 'hu', 'ar'] as const; +type Locale = (typeof LOCALES)[number]; + +const I18N_DIR = join(__dirname, '../../web/src/assets/i18n'); + +interface UnsavedDialogLabels { + CANCEL: string; + SETTINGS: { + UNSAVED_DIALOG_DISCARD: string; + UNSAVED_DIALOG_SAVE: string; + }; +} + +/** The shipped labels, in DOM order: dismiss, discard, primary save. */ +function expectedLabels(locale: Locale): string[] { + const labels = JSON.parse( + readFileSync(join(I18N_DIR, `${locale}.json`), 'utf8') + ) as UnsavedDialogLabels; + return [ + labels.CANCEL, + labels.SETTINGS.UNSAVED_DIALOG_DISCARD, + labels.SETTINGS.UNSAVED_DIALOG_SAVE, + ]; +} + +/** Below the 640px phone breakpoint the row stacks by design. */ +const PHONE_VIEWPORT = { width: 360, height: 740 }; + +/** + * Layout boxes relative to the actions row (its `position: relative` makes it + * the buttons' offset parent). Offsets ignore the dialog's scale-in transform, + * which `getBoundingClientRect` would include mid-animation. + */ +interface ActionBox { + testId: string | null; + top: number; + bottom: number; + left: number; + right: number; + width: number; + /** Label broken over more than one line inside its button. */ + labelLines: number; + /** Label wider than its button (clipped or spilling out). */ + overflows: boolean; +} + +interface ActionRow { + /** The row's content box, between its paddings. */ + contentLeft: number; + contentRight: number; + buttons: ActionBox[]; +} + +async function openSettings(page: Page) { + await page.locator('a[href$="/workspace/settings"]').click(); + await page.waitForURL(/\/workspace\/settings\/general$/); + await expect(page.locator('.settings-container')).toBeVisible(); +} + +async function useLanguage(page: Page, locale: Locale) { + if (locale === 'en') { + return; + } + await page.locator('[data-test-id="select-language"]').click(); + await page.locator(`mat-option[data-test-id="${locale}"]`).click(); + + const saveButton = page.locator('[data-test-id="save-settings"]'); + await saveButton.click(); + await expect(saveButton).toBeHidden(); +} + +/** + * Stages an edit, then leaves the settings area so the guard asks. Waiting for + * the locale's own labels proves the translation loaded before measuring. + */ +async function openUnsavedDialog(page: Page, locale: Locale): Promise { + await page + .locator('[data-test-id="select-theme"] [data-test-id="DARK_THEME"]') + .click(); + await expect( + page.locator('[data-test-id="settings-unsaved-bar"]') + ).toBeVisible(); + + // The rail's Dashboard link stays visible in the phone top bar, unlike + // the brand link. + await page + .getByRole('navigation') + .locator('a[href$="/workspace/dashboard"]') + .click(); + const dialog = page.getByRole('dialog'); + await expect(dialog).toBeVisible(); + await expect(dialog.locator('mat-dialog-actions button')).toHaveText( + expectedLabels(locale) + ); + return dialog; +} + +async function measureActions(dialog: Locator): Promise { + return dialog.locator('mat-dialog-actions').evaluate((row) => { + const rowStyle = getComputedStyle(row); + const buttons = Array.from(row.querySelectorAll('button')).map( + (button) => { + const label = + button.querySelector('.mdc-button__label') ?? button; + return { + testId: button.getAttribute('data-test-id'), + top: button.offsetTop, + bottom: button.offsetTop + button.offsetHeight, + left: button.offsetLeft, + right: button.offsetLeft + button.offsetWidth, + width: button.offsetWidth, + labelLines: label.getClientRects().length, + overflows: button.scrollWidth > button.clientWidth + 1, + }; + } + ); + return { + contentLeft: parseFloat(rowStyle.paddingLeft), + contentRight: + (row as HTMLElement).clientWidth - + parseFloat(rowStyle.paddingRight), + buttons, + }; + }); +} + +function expectReadableLabels(row: ActionRow) { + expect(row.buttons.map((button) => button.testId)).toEqual([ + 'unsaved-dialog-stay', + 'unsaved-dialog-discard', + 'unsaved-dialog-save', + ]); + for (const button of row.buttons) { + expect(button.labelLines, `${button.testId} label wraps`).toBe(1); + expect(button.overflows, `${button.testId} label is cut`).toBe(false); + expect(button.left).toBeGreaterThanOrEqual(row.contentLeft - 1); + expect(button.right).toBeLessThanOrEqual(row.contentRight + 1); + } +} + +test.describe('Settings unsaved-changes dialog layout', () => { + for (const locale of LOCALES) { + test(`@settings @web keeps the actions on one row (${locale})`, async ({ + page, + }) => { + await page.goto('/'); + await openSettings(page); + await useLanguage(page, locale); + const dialog = await openUnsavedDialog(page, locale); + + const row = await measureActions(dialog); + expectReadableLabels(row); + const [first] = row.buttons; + for (const button of row.buttons) { + expect( + Math.abs(button.top - first.top), + `${button.testId} left the row` + ).toBeLessThanOrEqual(1); + } + }); + } + + for (const locale of ['de', 'ru'] as const) { + test(`@settings @web stacks the actions full width on a phone (${locale})`, async ({ + page, + }) => { + await page.goto('/'); + await openSettings(page); + await useLanguage(page, locale); + await page.setViewportSize(PHONE_VIEWPORT); + const dialog = await openUnsavedDialog(page, locale); + + const row = await measureActions(dialog); + expectReadableLabels(row); + // One full-width action per row, in DOM order: dismiss on top, + // the primary save at the bottom. + row.buttons.forEach((button, index) => { + expect( + Math.abs( + button.width - (row.contentRight - row.contentLeft) + ) + ).toBeLessThanOrEqual(1); + if (index > 0) { + expect(button.top).toBeGreaterThanOrEqual( + row.buttons[index - 1].bottom + ); + } + }); + }); + } +}); diff --git a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.spec.ts b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.spec.ts new file mode 100644 index 000000000..ed0c10840 --- /dev/null +++ b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.spec.ts @@ -0,0 +1,78 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule, TranslateService } from '@ngx-translate/core'; +import { + SettingsUnsavedChangesDialogComponent, + SettingsUnsavedChangesDialogData, +} from './settings-unsaved-changes-dialog.component'; + +describe('SettingsUnsavedChangesDialogComponent', () => { + let fixture: ComponentFixture; + + async function render(data: SettingsUnsavedChangesDialogData) { + await TestBed.configureTestingModule({ + imports: [ + SettingsUnsavedChangesDialogComponent, + NoopAnimationsModule, + TranslateModule.forRoot(), + ], + providers: [{ provide: MAT_DIALOG_DATA, useValue: data }], + }).compileComponents(); + + const translate = TestBed.inject(TranslateService); + translate.setTranslation('en', { + CANCEL: 'Cancel', + SETTINGS: { + UNSAVED_DIALOG_DISCARD: 'Discard', + UNSAVED_DIALOG_SAVE: 'Save', + }, + }); + translate.use('en'); + + fixture = TestBed.createComponent( + SettingsUnsavedChangesDialogComponent + ); + fixture.detectChanges(); + } + + function actionButtons(): HTMLButtonElement[] { + return Array.from( + fixture.nativeElement.querySelectorAll( + '.unsaved-dialog__actions button' + ) + ); + } + + it('orders the actions dismiss first and the primary save last', async () => { + await render({ canSave: true }); + + expect( + actionButtons().map((button) => [ + button.getAttribute('data-test-id'), + button.textContent?.trim(), + ]) + ).toEqual([ + ['unsaved-dialog-stay', 'Cancel'], + ['unsaved-dialog-discard', 'Discard'], + ['unsaved-dialog-save', 'Save'], + ]); + }); + + it('keeps the initial focus on the dismiss so Enter stays safe', async () => { + await render({ canSave: true }); + + const [dismiss, discard, save] = actionButtons(); + expect(dismiss.hasAttribute('cdkFocusInitial')).toBe(true); + expect(discard.hasAttribute('cdkFocusInitial')).toBe(false); + expect(save.hasAttribute('cdkFocusInitial')).toBe(false); + }); + + it('disables save while the form cannot be saved', async () => { + await render({ canSave: false }); + + const [, discard, save] = actionButtons(); + expect(save.disabled).toBe(true); + expect(discard.disabled).toBe(false); + }); +}); diff --git a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts index cdd877ee3..9e3613540 100644 --- a/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts +++ b/apps/web/src/app/settings/settings-unsaved-changes-dialog.component.ts @@ -15,8 +15,8 @@ export interface SettingsUnsavedChangesDialogData { /** * Asked once, when the user is about to leave the settings area with staged * edits. Section switches never show this — the shared form survives them. - * "Keep editing" carries the initial focus so Enter is always the safe - * choice. + * "Cancel" (stay) carries the initial focus so Enter is always the safe + * choice. Labels are single verbs so the row fits one line in every locale. */ @Component({ imports: [MatButtonModule, MatDialogModule, MatIconModule, TranslateModule], @@ -36,6 +36,22 @@ export interface SettingsUnsavedChangesDialogData { height: 18px; } } + + /* Phone breakpoint: one full-width action per row, in DOM order, + instead of letting a long translation wrap the row unevenly. */ + @media (max-width: 640px) { + .unsaved-dialog__actions { + flex-direction: column; + align-items: stretch; + gap: 8px; + } + + .unsaved-dialog__actions + .mat-mdc-button-base + + .mat-mdc-button-base { + margin: 0; + } + } `, ], // eslint-disable-next-line @angular-eslint/prefer-on-push-component-change-detection -- Preserve pre-Angular 22 eager checking during the framework upgrade. @@ -55,21 +71,21 @@ export interface SettingsUnsavedChangesDialogData {

} - - + + - @if (channelLogo && !logoFailed) { -

{{ epgProgram.title }}

+

+ {{ epgProgram.title }} +

@if (epgProgram.desc) {

{{ epgProgram.desc }}

@@ -75,12 +68,66 @@ } - @if (dialogData.archiveUrlAvailable) { + @if ( + dialogData.archiveUrlAvailable || + dialogData.archiveDownloadAvailable + ) { + +
+ @if (dialogData.archiveUrlAvailable) { +

+ {{ + 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE' | translate + }} +

+ } +
+ @if (dialogData.archiveUrlAvailable) { + + } + @if (dialogData.archiveDownloadAvailable) { + + } +
+
+ } + @if (archiveUnavailableNote) {

- {{ 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE' | translate }} + {{ 'EPG.TIMELINE.ARCHIVE_UNAVAILABLE_NOTE' | translate }}

}
+ @if (primaryAction === 'live') { - } - @if (dialogData.archiveDownloadAvailable) { - - } - @if (archiveUnavailableNote) { - {{ - 'EPG.TIMELINE.ARCHIVE_UNAVAILABLE_NOTE' | translate - }} - } - -
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 29dc39af4..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 @@ -48,7 +48,7 @@ position: absolute; left: 14px; top: 12px; - max-width: calc(100% - 64px); + max-width: calc(100% - 28px); display: inline-flex; align-items: center; gap: 7px; @@ -75,36 +75,6 @@ white-space: nowrap; } -.epg-dialog__close { - position: absolute; - right: 12px; - top: 12px; - width: 30px; - height: 30px; - display: grid; - place-items: center; - border: none; - border-radius: 8px; - background: $surface-1; - color: $text-secondary; - cursor: pointer; - backdrop-filter: blur(6px); - transition: - color 120ms, - background 120ms; - - mat-icon { - font-size: 18px; - width: 18px; - height: 18px; - } - - &:hover { - color: $text-primary; - background: $surface-3; - } -} - /* ── body ── */ .epg-dialog__body { padding: 18px 22px 22px; @@ -129,13 +99,21 @@ opacity: 0.55; } -.epg-dialog__title { +/* `mat-dialog-title` names the dialog; keep the design's type and spacing + instead of Material's headline padding and baseline strut. */ +.epg-dialog__title.mat-mdc-dialog-title { margin: 0 0 12px; + padding: 0; + font-family: inherit; font-size: 21px; font-weight: 600; line-height: 1.25; letter-spacing: -0.01em; color: $text-primary; + + &::before { + display: none; + } } .epg-dialog__desc { @@ -180,22 +158,32 @@ } /* ── actions ── */ -.epg-dialog__actions { - flex-wrap: wrap; +.epg-dialog__archive { + margin: 0 0 18px; +} + +.epg-dialog__tools { display: flex; - align-items: center; + flex-wrap: wrap; gap: 10px; } -.epg-dialog__spacer { - flex: 1; -} - .epg-dialog__note { + margin: 0 0 12px; font-size: 12.5px; color: $text-tertiary; } +/* 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; +} + .epg-dialog__btn { display: inline-flex; align-items: center; @@ -236,8 +224,29 @@ } } -/* Let the dialog provide its own surface, border and shadow. */ -::ng-deep .mat-mdc-dialog-container { +/* 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. */ +::ng-deep .epg-programme-dialog-panel .mat-mdc-dialog-container { --mdc-dialog-container-color: transparent; .mat-mdc-dialog-surface, diff --git a/libs/ui/epg/src/lib/epg-programme-dialog.service.spec.ts b/libs/ui/epg/src/lib/epg-programme-dialog.service.spec.ts new file mode 100644 index 000000000..45ec561da --- /dev/null +++ b/libs/ui/epg/src/lib/epg-programme-dialog.service.spec.ts @@ -0,0 +1,122 @@ +import { Component, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatDialog } from '@angular/material/dialog'; +import { NoopAnimationsModule } from '@angular/platform-browser/animations'; +import { TranslateModule } from '@ngx-translate/core'; +import { SettingsStore } from '@iptvnator/services'; +import { EpgItemDialogData } from './epg-item-description/epg-item-description.component'; +import { + EPG_PROGRAMME_DIALOG_CONFIG, + EpgProgrammeDialogService, +} from './epg-programme-dialog.service'; + +@Component({ template: '' }) +class HostComponent {} + +const programme: EpgItemDialogData = { + start: '2026-04-05T11:30:00.000Z', + stop: '2026-04-05T12:30:00.000Z', + channel: 'channel-1', + title: 'Evening Show', + desc: 'An evening programme', + category: null, +}; + +describe('EpgProgrammeDialogService', () => { + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [NoopAnimationsModule, TranslateModule.forRoot()], + providers: [ + { + provide: SettingsStore, + useValue: { resolvedEpgOffsetMinutes: signal(0) }, + }, + ], + }); + }); + + afterEach(() => { + TestBed.inject(MatDialog).closeAll(); + }); + + async function openDialog( + data: EpgItemDialogData = programme + ): Promise { + const fixture = TestBed.createComponent(HostComponent); + TestBed.inject(EpgProgrammeDialogService).open(data); + fixture.detectChanges(); + await fixture.whenStable(); + fixture.detectChanges(); + + const container = document.querySelector( + 'mat-dialog-container' + ); + if (!container) { + throw new Error('The programme dialog did not open'); + } + return container; + } + + it('opens at 540px inside the panel class that scopes its surface', async () => { + const container = await openDialog(); + const pane = container.closest('.cdk-overlay-pane'); + + expect(pane?.style.width).toBe('540px'); + expect(pane?.classList).toContain( + EPG_PROGRAMME_DIALOG_CONFIG.panelClass + ); + }); + + it('names the dialog after the programme title', async () => { + const container = await openDialog(); + const labelId = container.getAttribute('aria-labelledby'); + + expect(labelId).toBeTruthy(); + expect( + document.getElementById(labelId ?? '')?.textContent?.trim() + ).toBe('Evening Show'); + }); + + it('offers one dismiss first and the primary action last', async () => { + const container = await openDialog({ + ...programme, + primaryAction: 'timeshift', + archiveUrlAvailable: true, + archiveDownloadAvailable: true, + }); + + // Only the Close button closes without a result; the hero carries + // no second close affordance. + const dismissButtons = container.querySelectorAll( + 'button[mat-dialog-close]' + ); + expect(dismissButtons).toHaveLength(1); + + const footer = Array.from( + container.querySelectorAll('.epg-dialog__actions button') + ); + expect(footer).toHaveLength(2); + expect(footer[0]).toBe(dismissButtons[0]); + expect(footer[1].classList).toContain('epg-dialog__btn--primary'); + + // The archive tools stay with their notice, outside the footer row. + const tools = container.querySelector('.epg-dialog__tools'); + expect( + tools?.querySelector('[data-testid="copy-catchup-url"]') + ).toBeTruthy(); + expect( + tools?.querySelector('[data-testid="download-catchup"]') + ).toBeTruthy(); + }); + + it('keeps the lone dismiss when the programme has no action', async () => { + const container = await openDialog(); + const footer = Array.from( + container.querySelectorAll('.epg-dialog__actions button') + ); + + expect(footer).toHaveLength(1); + expect(footer[0].classList).toContain('epg-dialog__close'); + expect(container.querySelector('.epg-dialog__tools')).toBeNull(); + }); +}); diff --git a/libs/ui/epg/src/lib/epg-programme-dialog.service.ts b/libs/ui/epg/src/lib/epg-programme-dialog.service.ts index 8c396de5b..235b3518a 100644 --- a/libs/ui/epg/src/lib/epg-programme-dialog.service.ts +++ b/libs/ui/epg/src/lib/epg-programme-dialog.service.ts @@ -1,5 +1,5 @@ import { inject, Injectable } from '@angular/core'; -import { MatDialog } from '@angular/material/dialog'; +import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; import { Observable } from 'rxjs'; import { EpgItemDescriptionComponent, @@ -7,10 +7,19 @@ import { EpgItemDialogData, } from './epg-item-description/epg-item-description.component'; +/** + * The one open config for the programme dialog. The panel class scopes the + * dialog's surface overrides, so they never leak into another open dialog. + */ +export const EPG_PROGRAMME_DIALOG_CONFIG = { + width: '540px', + panelClass: 'epg-programme-dialog-panel', +} as const satisfies MatDialogConfig; + /** * Opens the shared programme-details dialog and returns the chosen action. - * Extracted so any EPG surface (the timeline today, a list view later) can open - * it the same way and react to `live` / `timeshift`. + * Every EPG surface (timeline, list, guide, channel rows) opens it here, so + * the dialog has the same width wherever it is opened from. */ @Injectable({ providedIn: 'root' }) export class EpgProgrammeDialogService { @@ -18,7 +27,14 @@ export class EpgProgrammeDialogService { open(data: EpgItemDialogData): Observable { return this.dialog - .open(EpgItemDescriptionComponent, { width: '540px', data }) + .open< + EpgItemDescriptionComponent, + EpgItemDialogData, + EpgItemDialogAction + >(EpgItemDescriptionComponent, { + ...EPG_PROGRAMME_DIALOG_CONFIG, + data, + }) .afterClosed(); } }