mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-08 09:01:03 -08:00
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:
1 parent
d4118ad442
commit
cb317bad4c
31 files changed
+853
-187
No files matched your search
@@ -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
|
||||
|
||||
@@ -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": "إصدار جديد متاح",
|
||||
|
||||
@@ -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": "كاين إصدار جديد متوفر",
|
||||
|
||||
@@ -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": "Даступная новая версія праграмы",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Υπάρχει διαθέσιμη μια νέα έκδοση",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "新しいバージョンが利用可能です",
|
||||
|
||||
@@ -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": "새로운 버전이 있습니다",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Доступна новая версия приложения",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "有新版本可用 ",
|
||||
|
||||
@@ -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
|
||||
|
||||
+35
-8
@@ -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: {
|
||||
|
||||
+3
-9
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user