mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 13:46:16 -08:00
* 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>
246 lines
9.0 KiB
TypeScript
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);
|
|
}
|
|
});
|
|
});
|