diff --git a/apps/electron-backend-e2e/src/material-motion.e2e.ts b/apps/electron-backend-e2e/src/material-motion.e2e.ts index 67aea490b..4adc52cd0 100644 --- a/apps/electron-backend-e2e/src/material-motion.e2e.ts +++ b/apps/electron-backend-e2e/src/material-motion.e2e.ts @@ -25,6 +25,8 @@ import { interface MotionEvent { type: string; name: string; + /** The class attribute of the element the event fired on. */ + target: string; } type MotionWindow = Window & { __motionEvents?: MotionEvent[] }; @@ -43,11 +45,16 @@ async function recordMotion(page: Page): Promise { document.addEventListener( type, (event) => { + const target = event.target; events.push({ type, name: (event as AnimationEvent).animationName ?? (event as TransitionEvent).propertyName, + target: + target instanceof Element + ? (target.getAttribute('class') ?? '') + : '', }); }, true @@ -60,22 +67,37 @@ async function motionEvents(page: Page): Promise { return page.evaluate(() => (window as MotionWindow).__motionEvents ?? []); } +/** + * Waits for an event of that type and name, on an element wearing + * `targetClass` when given, among the events recorded from `since` on. + */ async function expectMotion( page: Page, type: string, - name: string + name: string, + { targetClass, since = 0 }: { targetClass?: string; since?: number } = {} ): Promise { await expect .poll( async () => - (await motionEvents(page)).some( - (event) => event.type === type && event.name === name - ), - `${type} ${name}` + (await motionEvents(page)) + .slice(since) + .some( + (event) => + event.type === type && + event.name === name && + (targetClass === undefined || + event.target.split(/\s+/).includes(targetClass)) + ), + `${type} ${name}${targetClass ? ` on .${targetClass}` : ''}` ) .toBe(true); } +async function recordedCount(page: Page): Promise { + return (await motionEvents(page)).length; +} + /** Opens the Clear EPG data confirmation from the EPG settings section. */ async function openClearEpgDialog(page: Page) { await openSettingsSection(page, 'epg'); @@ -148,6 +170,7 @@ test.describe('Material motion without the animations module', () => { // Dialog: Material drives it with transitions whose duration it // writes on the container; the surface scales in. Confirming // closes it after the exit transition and shows a snack-bar. + const beforeDialog = await recordedCount(page); const dialog = await openClearEpgDialog(page); await expect(dialog).not.toHaveClass(/_mat-animation-noopable/); await expect(dialog).toHaveClass(/mdc-dialog--open/); @@ -158,8 +181,20 @@ test.describe('Material motion without the animations module', () => { ) ) ).toMatch(/^[1-9]\d*ms$/); - await expectMotion(page, 'transitionend', 'transform'); + // The surface scales in while opening. + await expectMotion(page, 'transitionend', 'transform', { + targetClass: 'mat-mdc-dialog-surface', + since: beforeDialog, + }); + const beforeClose = await recordedCount(page); await dialog.getByTestId('confirm-dialog-confirm').click(); + // Closing fades the inner container out. Material detaches the + // container on a timer of the same length, so the fade's end + // may never be observed; its start is, in the first frame. + await expectMotion(page, 'transitionrun', 'opacity', { + targetClass: 'mat-mdc-dialog-inner-container', + since: beforeClose, + }); await expect(dialog).toHaveCount(0); const snackBar = page.locator('.mat-mdc-snack-bar-container'); diff --git a/tools/nx/check-material-theme-scope.mjs b/tools/nx/check-material-theme-scope.mjs index 8b14c431e..8b170e946 100644 --- a/tools/nx/check-material-theme-scope.mjs +++ b/tools/nx/check-material-theme-scope.mjs @@ -3,7 +3,7 @@ import { readFile } from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; -import { isScanned } from './check-theme-references.mjs'; +import { isScanned, stripComments } from './check-theme-references.mjs'; /** * `apps/web/src/m3-theme.scss` themes only the Material components the app @@ -20,7 +20,16 @@ const SCANNED_PATHSPECS = ['apps/web/*.ts', 'libs/*.ts']; export const THEME_FILE = 'apps/web/src/m3-theme.scss'; -const ENTRY_POINT_IMPORT = /from\s+['"]@angular\/material\/([a-z-]+)['"]/g; +/** + * Runtime imports of a Material entry point: a static import that is not + * type-only (a `type` specifier list leaves nothing in the bundle) and a + * dynamic `import()` such as the one LazyMatDialog makes. A type-only + * import renders no component and so needs no theme. + */ +const STATIC_IMPORT = + /(?-theme`, `mat.-color`) each Material @@ -72,12 +81,18 @@ export const THEMES_BY_ENTRY_POINT = new Map([ */ export const ALWAYS_THEMED = ['core']; -/** Material entry points the runtime sources import, with one importer each. */ +/** + * Material entry points the runtime sources import at runtime, statically or + * dynamically, with one importer each. + */ export function usedEntryPoints(sources) { const importers = new Map(); for (const { file, source } of sources) { - for (const match of source.matchAll(ENTRY_POINT_IMPORT)) { - if (!importers.has(match[1])) importers.set(match[1], file); + const code = stripComments(source); + for (const pattern of [STATIC_IMPORT, DYNAMIC_IMPORT]) { + for (const match of code.matchAll(pattern)) { + if (!importers.has(match[1])) importers.set(match[1], file); + } } } return importers; diff --git a/tools/nx/check-material-theme-scope.test.mjs b/tools/nx/check-material-theme-scope.test.mjs index 4843f750a..84af1c907 100644 --- a/tools/nx/check-material-theme-scope.test.mjs +++ b/tools/nx/check-material-theme-scope.test.mjs @@ -38,6 +38,30 @@ test('usedEntryPoints lists each Material entry point with its first importer', ); }); +test('usedEntryPoints counts dynamic imports and ignores type-only ones and comments', () => { + const importers = usedEntryPoints([ + { + file: 'lazy-mat-dialog.ts', + source: lines( + "import type { MatDialog } from '@angular/material/dialog';", + "type DialogModule = typeof import('@angular/material/dialog');", + 'loadDialogModule = () =>', + " import('@angular/material/dialog');" + ), + }, + { + file: 'types-only.ts', + source: lines( + "import type { MatTooltip } from '@angular/material/tooltip';", + "// import { MatChipsModule } from '@angular/material/chips';", + "/* import { MatTabsModule } from '@angular/material/tabs'; */" + ), + }, + ]); + + assert.deepEqual([...importers], [['dialog', 'lazy-mat-dialog.ts']]); +}); + test('requiredThemes always keeps core and expands the button entry point', () => { const required = requiredThemes( new Map([