Files
iptvnator/apps/electron-backend-e2e/src/sources-keyboard.e2e.ts
T
4grayandClaude Fable 5.1 3b2af73baa style(ui): ADR and one global kit layer for menus, select panels and destructive buttons (#1936)
* docs(ui): record the Angular Material role decision in the UI guidelines

Angular Material stays the behaviour and accessibility layer; the look comes
from the design kit through mat.*-overrides() on --app-* tokens in the shared
partials, kit-only components build on CDK, and plain actions may become
native buttons. Records the measured Material and CDK share of the initial
bytes, why a replacement would not pay, and the v22 behaviour changes to
keep covered in E2E.

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

* style(ui): one global kit layer for menus, select panels and destructive buttons

- `<mat-menu class="app-menu">` gets the kit panel from one rule in
  styles.scss; the two lazy components drop their `::ng-deep` copies.
- Select option panels follow the same panel: bordered, 10px radius, 6px
  padding, 36px rows at 13px, the chosen row on the list-selection tokens.
- `.app-destructive-button` is the kit's red tint on the `--app-danger-*`
  tokens (tinted fill, red text, no ripple, 40% opacity when disabled)
  instead of a solid error fill, for filled, text, outlined and icon
  buttons.
- The backdrop blur is scoped to the dark backdrop the CDK gives modal
  overlays; menus, selects and popovers open over a transparent backdrop
  that no longer matches the rule.
- `mat.elevation-classes()` is dropped: nothing uses `.mat-elevation-z*`,
  and its 25 rules cost 4.4 KB of the initial stylesheet. The stack now
  measures 1,595,843 initial bytes (was 1,597,764).

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

* test(e2e): wait for focus to enter an opened menu before navigating it

Material focuses a menu's first item after the next render, which the
zoneless scheduler runs on a later task than the keydown that opened it.
The panel is visible before that, so End sent as soon as it appeared still
reached the trigger: on the Linux runner the Sources row-menu delete test
then found "Remove playlist" inactive on every retry. `waitForMenuFocus`
polls for the active element inside the open menu; the Sources keyboard
and switcher keyboard tests use it before their first navigation key.

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

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-14 19:53:22 +02:00

246 lines
9.0 KiB
TypeScript

import type { Page } from '@playwright/test';
import {
closeElectronApp,
expect,
launchElectronApp,
openSources,
sourceRowByTitle,
test,
waitForM3uCatalog,
writeTemporaryM3uFile,
} from './electron-test-fixtures';
import { waitForMenuFocus } from './menu-keyboard';
// ---------------------------------------------------------------------------
// A source row opens from the keyboard. Each row has its own Tab stop: the
// title and meta block (`role="button"`, named after the source). The drag
// handle and the row actions are its siblings, so Tab reaches the row before
// its actions, Enter opens it like a click, and the row draws one ring
// while that element has keyboard focus.
// ---------------------------------------------------------------------------
type RowRing = {
focusVisible: boolean;
/** The focused element's own outline: the row draws its ring. */
ownOutline: string;
row: { style: string; width: string; offset: string; color: string };
/** `--app-focus-ring` resolved where the row sits. */
token: string;
};
/** Reads the ring of the row that holds keyboard focus. */
function readRowRing(page: Page): Promise<RowRing> {
return page.evaluate(() => {
const focused = document.activeElement as HTMLElement;
const row = focused.closest('.source-row') as HTMLElement;
const probe = document.createElement('span');
probe.style.color = 'var(--app-focus-ring)';
row.append(probe);
const token = getComputedStyle(probe).color;
probe.remove();
const style = getComputedStyle(row);
return {
focusVisible: focused.matches(':focus-visible'),
ownOutline: getComputedStyle(focused).outlineStyle,
row: {
style: style.outlineStyle,
width: style.outlineWidth,
offset: style.outlineOffset,
color: style.outlineColor,
},
token,
};
});
}
/** Describes the focused element, for a failure message. */
function describeFocus(page: Page): Promise<string> {
return page.evaluate(() => {
const el = document.activeElement;
if (!el) return '(none)';
const label = (el.getAttribute('aria-label') ?? el.textContent ?? '')
.replace(/\s+/g, ' ')
.trim()
.slice(0, 40);
return `${el.tagName.toLowerCase()}.${[...el.classList].join('.')} "${label}"`;
});
}
test.describe('Electron Sources keyboard', () => {
test('@m3u @electron Tab reaches a source row before its actions, shows a ring, and Enter switches to it', async ({
dataDir,
}) => {
const alpha = writeTemporaryM3uFile(dataDir, 'keyboard-alpha.m3u', [
{
groupTitle: 'News',
name: 'Keyboard Alpha News',
url: 'https://streams.example.test/keyboard-alpha.m3u8',
},
]);
const bravo = writeTemporaryM3uFile(dataDir, 'keyboard-bravo.m3u', [
{
groupTitle: 'Sports',
name: 'Keyboard Bravo Sports',
url: 'https://streams.example.test/keyboard-bravo.m3u8',
},
]);
const app = await launchElectronApp(dataDir, {
appArgs: [alpha, bravo],
});
const page = app.mainWindow;
try {
await page.waitForURL(/\/workspace\/playlists\/.+/, {
timeout: 30_000,
});
await openSources(page);
await expect(
page.locator('app-workspace-sources app-source-row')
).toHaveCount(2, {
timeout: 30_000,
});
// Bravo opened last, so it is the active source; open the other.
const row = sourceRowByTitle(page, 'keyboard-alpha').first();
await expect(row.locator('.source-row__body')).not.toHaveAttribute(
'aria-current',
'true'
);
const title = (
await row.locator('.source-row__name').textContent()
)?.trim();
expect(title).toBeTruthy();
// Enter the page from the keyboard ahead of the list, then Tab
// until focus lands in the row.
const start = page
.locator('app-workspace-sources-filters-panel button')
.first();
await start.focus();
await page.keyboard.press('Tab');
await page.keyboard.press('Shift+Tab');
await expect(start).toBeFocused();
const visited: string[] = [];
for (let presses = 0; presses < 40; presses++) {
await page.keyboard.press('Tab');
visited.push(await describeFocus(page));
const inRow = await row.evaluate((el) =>
el.contains(document.activeElement)
);
if (inRow) break;
}
// The row's first Tab stop opens it; its actions come after.
const open = row.getByRole('button', {
name: title,
exact: true,
});
await expect(
open,
`Tab stops up to the row: ${visited.join(' → ')}`
).toBeFocused();
const focused = await readRowRing(page);
expect(focused).toEqual({
focusVisible: true,
ownOutline: 'none',
row: {
style: 'solid',
width: '2px',
offset: '-2px',
color: focused.token,
},
token: expect.stringMatching(/^rgb/),
});
// The ring follows focus: it leaves with it.
await page.keyboard.press('Tab');
await expect(open).not.toBeFocused();
expect(
await row
.locator('.source-row')
.evaluate((el) => getComputedStyle(el).outlineStyle)
).toBe('none');
await page.keyboard.press('Shift+Tab');
await expect(open).toBeFocused();
await page.keyboard.press('Enter');
await waitForM3uCatalog(page);
const channels = page.getByTestId('channel-item');
await expect(
channels.filter({ hasText: 'Keyboard Alpha News' })
).toBeVisible();
await expect(
channels.filter({ hasText: 'Keyboard Bravo Sports' })
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
test('@m3u @electron deletes from the row menu and undoes from the toast, keyboard only', async ({
dataDir,
}) => {
const alpha = writeTemporaryM3uFile(dataDir, 'undo-alpha.m3u', [
{
groupTitle: 'News',
name: 'Undo Alpha News',
url: 'https://streams.example.test/undo-alpha.m3u8',
},
]);
const bravo = writeTemporaryM3uFile(dataDir, 'undo-bravo.m3u', [
{
groupTitle: 'Sports',
name: 'Undo Bravo Sports',
url: 'https://streams.example.test/undo-bravo.m3u8',
},
]);
const app = await launchElectronApp(dataDir, {
appArgs: [alpha, bravo],
});
const page = app.mainWindow;
const rows = page.locator('app-workspace-sources app-source-row');
try {
await page.waitForURL(/\/workspace\/playlists\/.+/, {
timeout: 30_000,
});
await openSources(page);
await expect(rows).toHaveCount(2, { timeout: 30_000 });
const before = await rows
.locator('.source-row__name')
.allTextContents();
// Focus the first row's ⋯ and open its menu from the keyboard.
const row = rows.first();
await row.locator('.source-row__more').focus();
await page.keyboard.press('Enter');
const remove = page.getByRole('menuitem', {
name: 'Remove playlist',
});
await expect(remove).toBeVisible();
await waitForMenuFocus(page);
await page.keyboard.press('End');
await expect(remove).toBeFocused();
await page.keyboard.press('Enter');
// The row leaves at once; Undo takes the focus the row had.
await expect(rows).toHaveCount(1);
const undo = page.locator(
'mat-snack-bar-container.app-toast .source-removal-toast__undo'
);
await expect(undo).toBeFocused();
await page.keyboard.press('Enter');
await expect(rows).toHaveCount(2);
expect(
await rows.locator('.source-row__name').allTextContents()
).toEqual(before);
await expect(
page.locator('mat-snack-bar-container.app-toast')
).toHaveCount(0);
} finally {
await closeElectronApp(app);
}
});
});