fix(ui): keep dialog action rows on one line (#1762)

* 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 <noreply@anthropic.com>

* 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 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 authored and GitHub committed 2026-09-30 21:54:09 +02:00
1 parent d4118ad442
commit cb317bad4c
31 files changed
+853 -187

No files matched your search

+9
View File
@@ -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.
@@ -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<Locator> {
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<HTMLElement>('.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<HTMLElement>('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,
@@ -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<Locator> {
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<ActionRow> {
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
);
}
});
});
}
});
@@ -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<SettingsUnsavedChangesDialogComponent>;
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);
});
});
@@ -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 {
</p>
}
</mat-dialog-content>
<mat-dialog-actions align="end">
<button
mat-button
[mat-dialog-close]="'discard'"
data-test-id="unsaved-dialog-discard"
>
{{ 'SETTINGS.UNSAVED_DIALOG_DISCARD' | translate }}
</button>
<mat-dialog-actions align="end" class="unsaved-dialog__actions">
<button
mat-button
mat-dialog-close
cdkFocusInitial
data-test-id="unsaved-dialog-stay"
>
{{ 'SETTINGS.UNSAVED_DIALOG_STAY' | translate }}
{{ 'CANCEL' | translate }}
</button>
<button
mat-button
[mat-dialog-close]="'discard'"
data-test-id="unsaved-dialog-discard"
>
{{ 'SETTINGS.UNSAVED_DIALOG_DISCARD' | translate }}
</button>
<button
mat-flat-button
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "تجاهل",
"UNSAVED_DIALOG_TITLE": "تغييرات غير محفوظة",
"UNSAVED_DIALOG_MESSAGE": "لديك تغييرات غير محفوظة في الإعدادات. هل تريد حفظها قبل المغادرة؟",
"UNSAVED_DIALOG_SAVE": "احفظ وغادر",
"UNSAVED_DIALOG_DISCARD": "غادر دون حفظ",
"UNSAVED_DIALOG_STAY": "متابعة التحرير",
"UNSAVED_DIALOG_SAVE": "حفظ",
"UNSAVED_DIALOG_DISCARD": "تجاهل",
"UNSAVED_DIALOG_INVALID_HINT": "بعض الحقول غير صالحة، لذلك لا يمكن حفظ التغييرات بعد.",
"BACK_TO_HOME": "العودة للرئيسية",
"NEW_VERSION_AVAILABLE": "إصدار جديد متاح",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "تجاهل",
"UNSAVED_DIALOG_TITLE": "تغييرات ماتسجلاتش",
"UNSAVED_DIALOG_MESSAGE": "عندك تغييرات ماتسجلاتش فالإعدادات. بغيتي تسجلهم قبل ما تخرج؟",
"UNSAVED_DIALOG_SAVE": "سجّل و خرج",
"UNSAVED_DIALOG_DISCARD": "خرج بلا ما تسجل",
"UNSAVED_DIALOG_STAY": "كمّل التعديل",
"UNSAVED_DIALOG_SAVE": "سجّل",
"UNSAVED_DIALOG_DISCARD": "تجاهل",
"UNSAVED_DIALOG_INVALID_HINT": "شي خانات ماشي صحيحين، علاش مايمكنش تسجل التغييرات دابا.",
"BACK_TO_HOME": "رجوع",
"NEW_VERSION_AVAILABLE": "كاين إصدار جديد متوفر",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Адмяніць змены",
"UNSAVED_DIALOG_TITLE": "Незахаваныя змены",
"UNSAVED_DIALOG_MESSAGE": "У вас ёсць незахаваныя змены налад. Захаваць іх перад выхадам?",
"UNSAVED_DIALOG_SAVE": "Захаваць і выйсці",
"UNSAVED_DIALOG_DISCARD": "Выйсці без захавання",
"UNSAVED_DIALOG_STAY": "Працягнуць рэдагаванне",
"UNSAVED_DIALOG_SAVE": "Захаваць",
"UNSAVED_DIALOG_DISCARD": "Не захоўваць",
"UNSAVED_DIALOG_INVALID_HINT": "Некаторыя палі запоўнены няправільна, таму змены пакуль нельга захаваць.",
"BACK_TO_HOME": "Назад",
"NEW_VERSION_AVAILABLE": "Даступная новая версія праграмы",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Verwerfen",
"UNSAVED_DIALOG_TITLE": "Ungespeicherte Änderungen",
"UNSAVED_DIALOG_MESSAGE": "Du hast ungespeicherte Änderungen an den Einstellungen. Möchtest du sie vor dem Verlassen speichern?",
"UNSAVED_DIALOG_SAVE": "Speichern und verlassen",
"UNSAVED_DIALOG_DISCARD": "Verlassen ohne zu speichern",
"UNSAVED_DIALOG_STAY": "Weiter bearbeiten",
"UNSAVED_DIALOG_SAVE": "Speichern",
"UNSAVED_DIALOG_DISCARD": "Verwerfen",
"UNSAVED_DIALOG_INVALID_HINT": "Einige Felder sind ungültig, daher können die Änderungen noch nicht gespeichert werden.",
"BACK_TO_HOME": "Zurück",
"NEW_VERSION_AVAILABLE": "EIne neue Version der Anwendung ist verfügbar",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Απόρριψη",
"UNSAVED_DIALOG_TITLE": "Μη αποθηκευμένες αλλαγές",
"UNSAVED_DIALOG_MESSAGE": "Έχετε μη αποθηκευμένες αλλαγές στις ρυθμίσεις. Θέλετε να τις αποθηκεύσετε πριν φύγετε;",
"UNSAVED_DIALOG_SAVE": "Αποθήκευση και έξοδος",
"UNSAVED_DIALOG_DISCARD": "Έξοδος χωρίς αποθήκευση",
"UNSAVED_DIALOG_STAY": "Συνέχεια επεξεργασίας",
"UNSAVED_DIALOG_SAVE": "Αποθήκευση",
"UNSAVED_DIALOG_DISCARD": "Απόρριψη",
"UNSAVED_DIALOG_INVALID_HINT": "Ορισμένα πεδία δεν είναι έγκυρα, επομένως οι αλλαγές δεν μπορούν να αποθηκευτούν ακόμη.",
"BACK_TO_HOME": "Πίσω",
"NEW_VERSION_AVAILABLE": "Υπάρχει διαθέσιμη μια νέα έκδοση",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Discard",
"UNSAVED_DIALOG_TITLE": "Unsaved changes",
"UNSAVED_DIALOG_MESSAGE": "You have unsaved settings changes. Do you want to save them before leaving?",
"UNSAVED_DIALOG_SAVE": "Save and leave",
"UNSAVED_DIALOG_DISCARD": "Leave without saving",
"UNSAVED_DIALOG_STAY": "Keep editing",
"UNSAVED_DIALOG_SAVE": "Save",
"UNSAVED_DIALOG_DISCARD": "Discard",
"UNSAVED_DIALOG_INVALID_HINT": "Some fields are invalid, so the changes cannot be saved yet.",
"BACK_TO_HOME": "Back",
"NEW_VERSION_AVAILABLE": "There is a new version available",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Descartar",
"UNSAVED_DIALOG_TITLE": "Cambios sin guardar",
"UNSAVED_DIALOG_MESSAGE": "Tienes cambios sin guardar en los ajustes. ¿Quieres guardarlos antes de salir?",
"UNSAVED_DIALOG_SAVE": "Guardar y salir",
"UNSAVED_DIALOG_DISCARD": "Salir sin guardar",
"UNSAVED_DIALOG_STAY": "Seguir editando",
"UNSAVED_DIALOG_SAVE": "Guardar",
"UNSAVED_DIALOG_DISCARD": "Descartar",
"UNSAVED_DIALOG_INVALID_HINT": "Algunos campos no son válidos, por lo que los cambios aún no se pueden guardar.",
"BACK_TO_HOME": "Volver al inicio",
"NEW_VERSION_AVAILABLE": "Hay una nueva versión disponible",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Annuler les modifications",
"UNSAVED_DIALOG_TITLE": "Modifications non enregistrées",
"UNSAVED_DIALOG_MESSAGE": "Vous avez des modifications non enregistrées dans les paramètres. Voulez-vous les enregistrer avant de quitter ?",
"UNSAVED_DIALOG_SAVE": "Enregistrer et quitter",
"UNSAVED_DIALOG_DISCARD": "Quitter sans enregistrer",
"UNSAVED_DIALOG_STAY": "Continuer la modification",
"UNSAVED_DIALOG_SAVE": "Enregistrer",
"UNSAVED_DIALOG_DISCARD": "Ne pas enregistrer",
"UNSAVED_DIALOG_INVALID_HINT": "Certains champs sont invalides, les modifications ne peuvent donc pas encore être enregistrées.",
"BACK_TO_HOME": "Retourner à l'accueil",
"NEW_VERSION_AVAILABLE": "Une nouvelle version est disponible",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Elvetés",
"UNSAVED_DIALOG_TITLE": "Nem mentett módosítások",
"UNSAVED_DIALOG_MESSAGE": "Nem mentett módosításaid vannak a beállításokban. Szeretnéd menteni őket, mielőtt kilépsz?",
"UNSAVED_DIALOG_SAVE": "Mentés és kilépés",
"UNSAVED_DIALOG_DISCARD": "Kilépés mentés nélkül",
"UNSAVED_DIALOG_STAY": "Szerkesztés folytatása",
"UNSAVED_DIALOG_SAVE": "Mentés",
"UNSAVED_DIALOG_DISCARD": "Elvetés",
"UNSAVED_DIALOG_INVALID_HINT": "Néhány mező érvénytelen, ezért a módosítások még nem menthetők.",
"BACK_TO_HOME": "Vissza",
"NEW_VERSION_AVAILABLE": "Új verzió érhető el",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Annulla modifiche",
"UNSAVED_DIALOG_TITLE": "Modifiche non salvate",
"UNSAVED_DIALOG_MESSAGE": "Hai modifiche alle impostazioni non salvate. Vuoi salvarle prima di uscire?",
"UNSAVED_DIALOG_SAVE": "Salva ed esci",
"UNSAVED_DIALOG_DISCARD": "Esci senza salvare",
"UNSAVED_DIALOG_STAY": "Continua a modificare",
"UNSAVED_DIALOG_SAVE": "Salva",
"UNSAVED_DIALOG_DISCARD": "Non salvare",
"UNSAVED_DIALOG_INVALID_HINT": "Alcuni campi non sono validi, quindi le modifiche non possono ancora essere salvate.",
"BACK_TO_HOME": "Indietro",
"NEW_VERSION_AVAILABLE": "È disponibile una nuova versione",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "破棄",
"UNSAVED_DIALOG_TITLE": "未保存の変更",
"UNSAVED_DIALOG_MESSAGE": "設定に未保存の変更があります。移動する前に保存しますか?",
"UNSAVED_DIALOG_SAVE": "保存して移動",
"UNSAVED_DIALOG_DISCARD": "保存せずに移動",
"UNSAVED_DIALOG_STAY": "編集を続ける",
"UNSAVED_DIALOG_SAVE": "保存",
"UNSAVED_DIALOG_DISCARD": "破棄",
"UNSAVED_DIALOG_INVALID_HINT": "一部の項目が無効なため、変更をまだ保存できません。",
"BACK_TO_HOME": "戻る",
"NEW_VERSION_AVAILABLE": "新しいバージョンが利用可能です",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "취소",
"UNSAVED_DIALOG_TITLE": "저장되지 않은 변경 사항",
"UNSAVED_DIALOG_MESSAGE": "설정에 저장되지 않은 변경 사항이 있습니다. 나가기 전에 저장하시겠습니까?",
"UNSAVED_DIALOG_SAVE": "저장하고 나가기",
"UNSAVED_DIALOG_DISCARD": "저장하지 않고 나가기",
"UNSAVED_DIALOG_STAY": "계속 편집",
"UNSAVED_DIALOG_SAVE": "저장",
"UNSAVED_DIALOG_DISCARD": "저장 안 함",
"UNSAVED_DIALOG_INVALID_HINT": "일부 항목이 유효하지 않아 변경 사항을 아직 저장할 수 없습니다.",
"BACK_TO_HOME": "홈으로",
"NEW_VERSION_AVAILABLE": "새로운 버전이 있습니다",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Verwerpen",
"UNSAVED_DIALOG_TITLE": "Niet-opgeslagen wijzigingen",
"UNSAVED_DIALOG_MESSAGE": "Je hebt niet-opgeslagen wijzigingen in de instellingen. Wil je ze opslaan voordat je vertrekt?",
"UNSAVED_DIALOG_SAVE": "Opslaan en verlaten",
"UNSAVED_DIALOG_DISCARD": "Verlaten zonder opslaan",
"UNSAVED_DIALOG_STAY": "Verder bewerken",
"UNSAVED_DIALOG_SAVE": "Opslaan",
"UNSAVED_DIALOG_DISCARD": "Verwerpen",
"UNSAVED_DIALOG_INVALID_HINT": "Sommige velden zijn ongeldig, dus de wijzigingen kunnen nog niet worden opgeslagen.",
"BACK_TO_HOME": "Terug",
"NEW_VERSION_AVAILABLE": "Een nieuwe versie is beschikbaar",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Odrzuć",
"UNSAVED_DIALOG_TITLE": "Niezapisane zmiany",
"UNSAVED_DIALOG_MESSAGE": "Masz niezapisane zmiany ustawień. Czy chcesz je zapisać przed wyjściem?",
"UNSAVED_DIALOG_SAVE": "Zapisz i wyjdź",
"UNSAVED_DIALOG_DISCARD": "Wyjdź bez zapisywania",
"UNSAVED_DIALOG_STAY": "Kontynuuj edycję",
"UNSAVED_DIALOG_SAVE": "Zapisz",
"UNSAVED_DIALOG_DISCARD": "Odrzuć",
"UNSAVED_DIALOG_INVALID_HINT": "Niektóre pola są nieprawidłowe, więc zmian nie można jeszcze zapisać.",
"BACK_TO_HOME": "Wróć",
"NEW_VERSION_AVAILABLE": "Dostępna jest nowa wersja",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Descartar",
"UNSAVED_DIALOG_TITLE": "Alterações não salvas",
"UNSAVED_DIALOG_MESSAGE": "Você tem alterações não salvas nas configurações. Deseja salvá-las antes de sair?",
"UNSAVED_DIALOG_SAVE": "Salvar e sair",
"UNSAVED_DIALOG_DISCARD": "Sair sem salvar",
"UNSAVED_DIALOG_STAY": "Continuar editando",
"UNSAVED_DIALOG_SAVE": "Salvar",
"UNSAVED_DIALOG_DISCARD": "Descartar",
"UNSAVED_DIALOG_INVALID_HINT": "Alguns campos são inválidos, então as alterações ainda não podem ser salvas.",
"BACK_TO_HOME": "Voltar",
"NEW_VERSION_AVAILABLE": "Há uma nova versão disponível",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Отменить изменения",
"UNSAVED_DIALOG_TITLE": "Несохранённые изменения",
"UNSAVED_DIALOG_MESSAGE": "У вас есть несохранённые изменения настроек. Сохранить их перед выходом?",
"UNSAVED_DIALOG_SAVE": "Сохранить и выйти",
"UNSAVED_DIALOG_DISCARD": "Выйти без сохранения",
"UNSAVED_DIALOG_STAY": "Продолжить редактирование",
"UNSAVED_DIALOG_SAVE": "Сохранить",
"UNSAVED_DIALOG_DISCARD": "Не сохранять",
"UNSAVED_DIALOG_INVALID_HINT": "Некоторые поля заполнены неверно, поэтому изменения пока нельзя сохранить.",
"BACK_TO_HOME": "Назад",
"NEW_VERSION_AVAILABLE": "Доступна новая версия приложения",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "Vazgeç",
"UNSAVED_DIALOG_TITLE": "Kaydedilmemiş değişiklikler",
"UNSAVED_DIALOG_MESSAGE": "Ayarlarda kaydedilmemiş değişiklikleriniz var. Çıkmadan önce kaydetmek ister misiniz?",
"UNSAVED_DIALOG_SAVE": "Kaydet ve çık",
"UNSAVED_DIALOG_DISCARD": "Kaydetmeden çık",
"UNSAVED_DIALOG_STAY": "Düzenlemeye devam et",
"UNSAVED_DIALOG_SAVE": "Kaydet",
"UNSAVED_DIALOG_DISCARD": "Kaydetme",
"UNSAVED_DIALOG_INVALID_HINT": "Bazı alanlar geçersiz olduğundan değişiklikler henüz kaydedilemiyor.",
"BACK_TO_HOME": "Ana sayfaya dön",
"NEW_VERSION_AVAILABLE": "Yeni bir sürüm mevcut",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "放弃更改",
"UNSAVED_DIALOG_TITLE": "未保存的更改",
"UNSAVED_DIALOG_MESSAGE": "设置中有未保存的更改。要在离开前保存吗?",
"UNSAVED_DIALOG_SAVE": "保存并离开",
"UNSAVED_DIALOG_DISCARD": "不保存直接离开",
"UNSAVED_DIALOG_STAY": "继续编辑",
"UNSAVED_DIALOG_SAVE": "保存",
"UNSAVED_DIALOG_DISCARD": "放弃",
"UNSAVED_DIALOG_INVALID_HINT": "部分字段无效,因此暂时无法保存更改。",
"BACK_TO_HOME": "回到主页",
"NEW_VERSION_AVAILABLE": "有新版本可用 ",
+2 -3
View File
@@ -340,9 +340,8 @@
"DISCARD_CHANGES": "捨棄變更",
"UNSAVED_DIALOG_TITLE": "未儲存的變更",
"UNSAVED_DIALOG_MESSAGE": "設定中有未儲存的變更。要在離開前儲存嗎?",
"UNSAVED_DIALOG_SAVE": "儲存並離開",
"UNSAVED_DIALOG_DISCARD": "不儲存直接離開",
"UNSAVED_DIALOG_STAY": "繼續編輯",
"UNSAVED_DIALOG_SAVE": "儲存",
"UNSAVED_DIALOG_DISCARD": "捨棄",
"UNSAVED_DIALOG_INVALID_HINT": "部分欄位無效,因此暫時無法儲存變更。",
"BACK_TO_HOME": "返回主頁",
"NEW_VERSION_AVAILABLE": "有新版本可用",
@@ -652,6 +652,34 @@ Settings use the same system but are flatter than content-heavy views.
- Neutral rows can use low-opacity dark overlays
- Keep strong blue tint reserved for active sections and selected items
## Dialogs
- **Name.** Every dialog has a `mat-dialog-title`; Material points the
container's `aria-labelledby` at it. A custom-styled title keeps the
directive and overrides Material's headline padding and 40px `::before`
strut, as the programme dialog does.
- **Order.** Dismiss first, primary last. With end alignment the primary sits
on the inline end: the right in LTR, the left in RTL.
- **One dismiss.** Offer one visible dismiss: a footer "Cancel"/"Close" or a
header close icon, not both. Escape and the backdrop still close.
- **One row.** Action labels are short verbs ("Cancel", "Discard", "Save"),
not phrases, so the row fits one line in every locale at the dialog width.
Secondary actions tied to one part of the content stay with that content
(the programme dialog's archive tools sit under their notice), so the
footer is only the dismiss and the primary.
- **Phone.** At the phone breakpoint an action row may stack: one full-width
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. 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
timeline, list, guide and channel rows, with a panel class that scopes its
surface overrides.
## Phone Layout
`640px` is the phone breakpoint. Use `@media (max-width: 640px)` rather than
@@ -2,18 +2,18 @@ import { DatePipe } from '@angular/common';
import { signal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { NoopAnimationsModule } from '@angular/platform-browser/animations';
import { MatDialog } from '@angular/material/dialog';
import { TranslateModule } from '@ngx-translate/core';
import { SettingsStore } from '@iptvnator/services';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { EpgProgrammeDialogService } from '@iptvnator/ui/epg';
import { ChannelListItemComponent } from './channel-list-item.component';
describe('ChannelListItemComponent', () => {
let fixture: ComponentFixture<ChannelListItemComponent>;
let dialog: { open: jest.Mock };
let programmeDialog: { open: jest.Mock };
beforeEach(async () => {
dialog = {
programmeDialog = {
open: jest.fn(),
};
@@ -25,8 +25,8 @@ describe('ChannelListItemComponent', () => {
],
providers: [
{
provide: MatDialog,
useValue: dialog,
provide: EpgProgrammeDialogService,
useValue: programmeDialog,
},
],
}).compileComponents();
@@ -140,6 +140,27 @@ describe('ChannelListItemComponent', () => {
expect(buttons[1]).not.toContain('slot-empty');
});
it('opens the programme through the shared dialog service', () => {
const program: EpgProgram = {
start: '2026-04-05 05:30:00',
stop: '2026-04-05 06:00:00',
channel: 'channel-1',
title: 'Current Show',
desc: 'Current description',
category: null,
};
fixture.componentRef.setInput('name', 'Info Channel');
fixture.componentRef.setInput('epgProgram', program);
fixture.detectChanges();
// The service owns the open config, so the row opens the dialog at
// the same width as the timeline, list and guide.
fixture.nativeElement.querySelector('.program-info-button').click();
expect(programmeDialog.open).toHaveBeenCalledTimes(1);
expect(programmeDialog.open).toHaveBeenCalledWith(program);
});
it('keeps an inert info slot while the row has no programme', () => {
fixture.componentRef.setInput('name', 'No EPG Yet');
fixture.componentRef.setInput('showFavoriteButton', true);
@@ -155,7 +176,7 @@ describe('ChannelListItemComponent', () => {
expect(slot.disabled).toBe(true);
slot.click();
expect(dialog.open).not.toHaveBeenCalled();
expect(programmeDialog.open).not.toHaveBeenCalled();
});
it('renders the catch-up badge only when catch-up is available', () => {
@@ -271,7 +292,10 @@ describe('ChannelListItemComponent', () => {
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialog, useValue: dialog },
{
provide: EpgProgrammeDialogService,
useValue: programmeDialog,
},
{
provide: SettingsStore,
useValue: {
@@ -307,7 +331,10 @@ describe('ChannelListItemComponent with an EPG display offset', () => {
TranslateModule.forRoot(),
],
providers: [
{ provide: MatDialog, useValue: { open: jest.fn() } },
{
provide: EpgProgrammeDialogService,
useValue: { open: jest.fn() },
},
{
provide: SettingsStore,
useValue: {
@@ -11,14 +11,10 @@ import {
signal,
} from '@angular/core';
import { MatIconButton } from '@angular/material/button';
import { MatDialog } from '@angular/material/dialog';
import { MatIcon } from '@angular/material/icon';
import { MatTooltip } from '@angular/material/tooltip';
import { TranslatePipe } from '@ngx-translate/core';
import {
EpgItemDescriptionComponent,
getProgramTimeMs,
} from '@iptvnator/ui/epg';
import { EpgProgrammeDialogService, getProgramTimeMs } from '@iptvnator/ui/epg';
import { EpgProgram } from '@iptvnator/shared/interfaces';
import { SettingsStore } from '@iptvnator/services';
import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
@@ -39,7 +35,7 @@ import { applyChannelNameStrip } from '@iptvnator/shared/m3u-utils';
],
})
export class ChannelListItemComponent {
private readonly dialog = inject(MatDialog);
private readonly programmeDialog = inject(EpgProgrammeDialogService);
private readonly logoFailed = signal(false);
private readonly settingsStore = inject(SettingsStore);
readonly epgOffsetMinutes = this.settingsStore.resolvedEpgOffsetMinutes;
@@ -99,9 +95,7 @@ export class ChannelListItemComponent {
*/
showProgramDescription(program: EpgProgram, event: MouseEvent): void {
event.stopPropagation();
this.dialog.open(EpgItemDescriptionComponent, {
data: program,
});
this.programmeDialog.open(program);
}
/** Guarded click for the reserved (possibly still empty) info slot. */
@@ -6,15 +6,6 @@
<span class="epg-dialog__chan-name">{{ channelName }}</span>
</span>
}
<button
type="button"
class="epg-dialog__close"
mat-dialog-close
[attr.aria-label]="'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate"
>
<mat-icon>close</mat-icon>
</button>
@if (channelLogo && !logoFailed) {
<img
class="epg-dialog__logo"
@@ -47,7 +38,9 @@
}
</div>
<h3 class="epg-dialog__title">{{ epgProgram.title }}</h3>
<h2 mat-dialog-title class="epg-dialog__title">
{{ epgProgram.title }}
</h2>
@if (epgProgram.desc) {
<p class="epg-dialog__desc">{{ epgProgram.desc }}</p>
@@ -75,12 +68,66 @@
</div>
}
@if (dialogData.archiveUrlAvailable) {
@if (
dialogData.archiveUrlAvailable ||
dialogData.archiveDownloadAvailable
) {
<!-- Archive tools sit with their notice, so the footer below keeps
one row: the dismiss first, the primary action last. -->
<div class="epg-dialog__archive">
@if (dialogData.archiveUrlAvailable) {
<p class="epg-dialog__note">
{{
'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE' | translate
}}
</p>
}
<div class="epg-dialog__tools">
@if (dialogData.archiveUrlAvailable) {
<button
type="button"
class="epg-dialog__btn epg-dialog__btn--ghost"
data-testid="copy-catchup-url"
[mat-dialog-close]="'copy-catchup-url'"
>
<mat-icon>content_copy</mat-icon>
{{
'EPG.PROGRAM_DIALOG.COPY_ARCHIVE_URL'
| translate
}}
</button>
}
@if (dialogData.archiveDownloadAvailable) {
<button
type="button"
class="epg-dialog__btn epg-dialog__btn--ghost"
data-testid="download-catchup"
[mat-dialog-close]="'download-catchup'"
>
<mat-icon>download</mat-icon>
{{
'EPG.PROGRAM_DIALOG.DOWNLOAD_ARCHIVE'
| translate
}}
</button>
}
</div>
</div>
}
@if (archiveUnavailableNote) {
<p class="epg-dialog__note">
{{ 'EPG.PROGRAM_DIALOG.ARCHIVE_URL_NOTICE' | translate }}
{{ 'EPG.TIMELINE.ARCHIVE_UNAVAILABLE_NOTE' | translate }}
</p>
}
<div class="epg-dialog__actions">
<button
type="button"
class="epg-dialog__btn epg-dialog__btn--ghost epg-dialog__close"
mat-dialog-close
cdkFocusInitial
>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
@if (primaryAction === 'live') {
<button
type="button"
@@ -100,41 +147,6 @@
{{ 'EPG.TIMELINE.WATCH_FROM_START' | translate }}
</button>
}
@if (dialogData.archiveUrlAvailable) {
<button
type="button"
class="epg-dialog__btn epg-dialog__btn--ghost"
data-testid="copy-catchup-url"
[mat-dialog-close]="'copy-catchup-url'"
>
<mat-icon>content_copy</mat-icon>
{{ 'EPG.PROGRAM_DIALOG.COPY_ARCHIVE_URL' | translate }}
</button>
}
@if (dialogData.archiveDownloadAvailable) {
<button
type="button"
class="epg-dialog__btn epg-dialog__btn--ghost"
data-testid="download-catchup"
[mat-dialog-close]="'download-catchup'"
>
<mat-icon>download</mat-icon>
{{ 'EPG.PROGRAM_DIALOG.DOWNLOAD_ARCHIVE' | translate }}
</button>
}
@if (archiveUnavailableNote) {
<span class="epg-dialog__note">{{
'EPG.TIMELINE.ARCHIVE_UNAVAILABLE_NOTE' | translate
}}</span>
}
<span class="epg-dialog__spacer"></span>
<button
type="button"
class="epg-dialog__btn epg-dialog__btn--ghost"
mat-dialog-close
>
{{ 'HOME.PLAYLISTS.INFO_DIALOG.CLOSE' | translate }}
</button>
</div>
</div>
</div>
@@ -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,
@@ -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<HTMLElement> {
const fixture = TestBed.createComponent(HostComponent);
TestBed.inject(EpgProgrammeDialogService).open(data);
fixture.detectChanges();
await fixture.whenStable();
fixture.detectChanges();
const container = document.querySelector<HTMLElement>(
'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<HTMLElement>('.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();
});
});
@@ -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<EpgItemDialogAction | undefined> {
return this.dialog
.open(EpgItemDescriptionComponent, { width: '540px', data })
.open<
EpgItemDescriptionComponent,
EpgItemDialogData,
EpgItemDialogAction
>(EpgItemDescriptionComponent, {
...EPG_PROGRAMME_DIALOG_CONFIG,
data,
})
.afterClosed();
}
}