mirror of
https://github.com/4gray/iptvnator.git
synced 2026-10-15 05:36:17 -08:00
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>
This commit is contained in:
1 parent
88765c7bf0
commit
3b2af73baa
10 files changed
+253
-59
No files matched your search
@@ -0,0 +1,21 @@
|
||||
import { expect, Locator, Page } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* Wait until keyboard focus has entered the open `mat-menu`, and return it.
|
||||
*
|
||||
* Material moves focus to the first item after the next render, which the
|
||||
* zoneless scheduler runs on a later task than the keydown that opened the
|
||||
* menu. The panel is visible before that, so a navigation key sent as soon
|
||||
* as it appears still reaches the trigger and the menu never sees it.
|
||||
*/
|
||||
export async function waitForMenuFocus(page: Page): Promise<Locator> {
|
||||
const menu = page.getByRole('menu');
|
||||
await expect(menu).toBeVisible();
|
||||
await expect
|
||||
.poll(
|
||||
() => menu.evaluate((el) => el.contains(document.activeElement)),
|
||||
{ message: 'focus inside the open menu' }
|
||||
)
|
||||
.toBe(true);
|
||||
return menu;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
writeTemporaryM3uFile,
|
||||
xtreamMockServer,
|
||||
} from './electron-test-fixtures';
|
||||
import { waitForMenuFocus } from './menu-keyboard';
|
||||
import {
|
||||
routePlayableStreams,
|
||||
startAndConfirmPlayback,
|
||||
@@ -572,8 +573,7 @@ test.describe('Electron Playlist Switcher', () => {
|
||||
await expect(search).toBeFocused();
|
||||
await page.keyboard.press('ArrowDown');
|
||||
await page.keyboard.press('Shift+F10');
|
||||
const rowMenu = page.locator('.mat-mdc-menu-panel');
|
||||
await expect(rowMenu).toBeVisible();
|
||||
const rowMenu = await waitForMenuFocus(page);
|
||||
await page.keyboard.press('End');
|
||||
await expect(
|
||||
rowMenu.getByRole('menuitem', { name: /Remove/ })
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
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
|
||||
@@ -217,6 +218,7 @@ test.describe('Electron Sources keyboard', () => {
|
||||
name: 'Remove playlist',
|
||||
});
|
||||
await expect(remove).toBeVisible();
|
||||
await waitForMenuFocus(page);
|
||||
await page.keyboard.press('End');
|
||||
await expect(remove).toBeFocused();
|
||||
await page.keyboard.press('Enter');
|
||||
|
||||
@@ -47,16 +47,27 @@ async function outlineContrast(
|
||||
}, fillSelector);
|
||||
}
|
||||
|
||||
/** Resolves a CSS color (including var() and color-mix()) to rgb()/rgba(). */
|
||||
async function resolveColor(page: Page, value: string): Promise<string> {
|
||||
return page.evaluate((css) => {
|
||||
const probe = document.createElement('div');
|
||||
probe.style.color = css;
|
||||
document.body.appendChild(probe);
|
||||
const color = getComputedStyle(probe).color;
|
||||
probe.remove();
|
||||
return color;
|
||||
}, value);
|
||||
/**
|
||||
* Resolves a CSS color (including var() and color-mix()) the way the given
|
||||
* property serializes it, so it compares with a computed `color` or
|
||||
* `backgroundColor`.
|
||||
*/
|
||||
async function resolveColor(
|
||||
page: Page,
|
||||
value: string,
|
||||
property: 'color' | 'backgroundColor' = 'color'
|
||||
): Promise<string> {
|
||||
return page.evaluate(
|
||||
({ css, prop }) => {
|
||||
const probe = document.createElement('div');
|
||||
probe.style[prop] = css;
|
||||
document.body.appendChild(probe);
|
||||
const color = getComputedStyle(probe)[prop];
|
||||
probe.remove();
|
||||
return color;
|
||||
},
|
||||
{ css: value, prop: property }
|
||||
);
|
||||
}
|
||||
|
||||
async function systemVariable(page: Page, name: string): Promise<string> {
|
||||
@@ -200,7 +211,7 @@ test.describe('Theme tokens', () => {
|
||||
}
|
||||
});
|
||||
|
||||
test('@theme @electron destructive actions name the action and use the error color', async ({
|
||||
test('@theme @electron destructive actions name the action and use the danger tint', async ({
|
||||
dataDir,
|
||||
}) => {
|
||||
const app = await launchElectronApp(dataDir);
|
||||
@@ -211,18 +222,35 @@ test.describe('Theme tokens', () => {
|
||||
|
||||
for (const theme of ['light', 'dark'] as const) {
|
||||
await applyTheme(page, theme);
|
||||
const error = await resolveColor(page, 'var(--mat-sys-error)');
|
||||
const dangerText = await resolveColor(
|
||||
page,
|
||||
'var(--app-danger-text)'
|
||||
);
|
||||
const dangerSurface = await resolveColor(
|
||||
page,
|
||||
'var(--app-danger-surface)',
|
||||
'backgroundColor'
|
||||
);
|
||||
const trigger = page.getByRole('button', {
|
||||
name: 'Clear EPG data',
|
||||
exact: true,
|
||||
});
|
||||
// The `warn` color input was a no-op with M3: the trigger
|
||||
// rendered in the primary color.
|
||||
// rendered in the primary color. The kit draws a destructive
|
||||
// button as a red tint, never a solid error fill, and the
|
||||
// outlined trigger carries the same tint as a filled one.
|
||||
await expect
|
||||
.poll(() =>
|
||||
trigger.evaluate((el) => getComputedStyle(el).color)
|
||||
)
|
||||
.toBe(error);
|
||||
.toBe(dangerText);
|
||||
await expect
|
||||
.poll(() =>
|
||||
trigger.evaluate(
|
||||
(el) => getComputedStyle(el).backgroundColor
|
||||
)
|
||||
)
|
||||
.toBe(dangerSurface);
|
||||
await trigger.click();
|
||||
|
||||
const dialog = page.locator('mat-dialog-container');
|
||||
@@ -239,7 +267,12 @@ test.describe('Theme tokens', () => {
|
||||
(el) => getComputedStyle(el).backgroundColor
|
||||
)
|
||||
)
|
||||
.toBe(error);
|
||||
.toBe(dangerSurface);
|
||||
await expect
|
||||
.poll(() =>
|
||||
confirm.evaluate((el) => getComputedStyle(el).color)
|
||||
)
|
||||
.toBe(dangerText);
|
||||
|
||||
await dialog
|
||||
.getByRole('button', { name: 'Cancel', exact: true })
|
||||
|
||||
Reference in new issue
Block a user