diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b5a581c80..5d19da430 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -430,6 +430,12 @@ jobs: - name: Validate icon-only button names run: pnpm run a11y:icon-buttons:validate + # The form-field defaults travel with AppFormFieldModule, not a + # root provider: a component that renders + # without it silently gets Material's filled look. + - name: Validate form-field defaults + run: pnpm run styles:form-field-defaults:validate + # Nx rejects a non-parallel task with continuous dependencies, and # the Playwright plugin infers serve dependencies only when CI is # unset, so this checks both the local and the CI inference. diff --git a/apps/web/src/app/app.config.ts b/apps/web/src/app/app.config.ts index 6a5b072bb..27e447a27 100644 --- a/apps/web/src/app/app.config.ts +++ b/apps/web/src/app/app.config.ts @@ -9,7 +9,6 @@ import { OverlayContainer, } from '@angular/cdk/overlay'; import { ApplicationConfig, inject, importProvidersFrom } from '@angular/core'; -import { MAT_FORM_FIELD_DEFAULT_OPTIONS } from '@angular/material/form-field'; import { provideAnimations } from '@angular/platform-browser/animations'; import { provideRouter, withComponentInputBinding } from '@angular/router'; import { provideServiceWorker } from '@angular/service-worker'; @@ -162,9 +161,5 @@ export const appConfig: ApplicationConfig = { ...provideParentalLockPrompt(), provideParentalLockPlaylistCleanup(), ...provideXtreamDataSource(), - { - provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, - useValue: { appearance: 'outline', subscriptSizing: 'dynamic' }, - }, ], }; diff --git a/apps/web/src/app/settings/settings-about-section.component.ts b/apps/web/src/app/settings/settings-about-section.component.ts index 511206c62..92f39fcb9 100644 --- a/apps/web/src/app/settings/settings-about-section.component.ts +++ b/apps/web/src/app/settings/settings-about-section.component.ts @@ -8,10 +8,10 @@ import { } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; import { TranslateModule } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { AppUpdateChannel, APP_UPDATE_CHANNELS, @@ -30,7 +30,7 @@ interface SettingsUpdateStatusChip { selector: 'app-settings-about-section', imports: [ MatButtonModule, - MatFormFieldModule, + AppFormFieldModule, MatIconModule, MatSelectModule, ReactiveFormsModule, diff --git a/apps/web/src/app/settings/settings-epg-section.component.ts b/apps/web/src/app/settings/settings-epg-section.component.ts index d4ec29556..f47ada286 100644 --- a/apps/web/src/app/settings/settings-epg-section.component.ts +++ b/apps/web/src/app/settings/settings-epg-section.component.ts @@ -12,12 +12,12 @@ import { ReactiveFormsModule, } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { EpgViewMode } from '@iptvnator/shared/interfaces'; import { EpgSourceStatusComponent } from '@iptvnator/ui/epg'; import { TranslateModule } from '@ngx-translate/core'; @@ -29,7 +29,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render' imports: [ EpgSourceStatusComponent, MatButtonModule, - MatFormFieldModule, + AppFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, diff --git a/apps/web/src/app/settings/settings-general-section.component.ts b/apps/web/src/app/settings/settings-general-section.component.ts index 7bb7b4f33..6ea9d8ef2 100644 --- a/apps/web/src/app/settings/settings-general-section.component.ts +++ b/apps/web/src/app/settings/settings-general-section.component.ts @@ -7,11 +7,11 @@ import { ChangeDetectionStrategy, } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { TranslateModule } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { CoverSize, Language, Theme } from '@iptvnator/shared/interfaces'; import { CoverSizeOption, @@ -25,7 +25,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render' selector: 'app-settings-general-section', imports: [ CommonModule, - MatFormFieldModule, + AppFormFieldModule, MatIconModule, MatSelectModule, MatSlideToggleModule, diff --git a/apps/web/src/app/settings/settings-parental-section.component.ts b/apps/web/src/app/settings/settings-parental-section.component.ts index 7e874c010..a2c5a3c1b 100644 --- a/apps/web/src/app/settings/settings-parental-section.component.ts +++ b/apps/web/src/app/settings/settings-parental-section.component.ts @@ -1,6 +1,5 @@ import { Component, input, output, ViewEncapsulation } from '@angular/core'; import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatSelectModule } from '@angular/material/select'; import { @@ -8,13 +7,14 @@ import { MatSlideToggleModule, } from '@angular/material/slide-toggle'; import { TranslateModule } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { ParentalLockRelockMinutes } from '@iptvnator/shared/interfaces'; @Component({ selector: 'app-settings-parental-lock-section', imports: [ MatButtonModule, - MatFormFieldModule, + AppFormFieldModule, MatIconModule, MatSelectModule, MatSlideToggleModule, diff --git a/apps/web/src/app/settings/settings-playback-section.component.ts b/apps/web/src/app/settings/settings-playback-section.component.ts index 5fd306029..82c6e82c0 100644 --- a/apps/web/src/app/settings/settings-playback-section.component.ts +++ b/apps/web/src/app/settings/settings-playback-section.component.ts @@ -8,13 +8,13 @@ import { } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatSelectModule } from '@angular/material/select'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { StreamFormat, VideoPlayer, @@ -28,7 +28,7 @@ import { markSectionForCheckOnFormEvents } from './settings-section-form-render' imports: [ CommonModule, MatButtonModule, - MatFormFieldModule, + AppFormFieldModule, MatIconModule, MatInputModule, MatSelectModule, diff --git a/apps/web/src/app/settings/settings-remote-control-section.component.ts b/apps/web/src/app/settings/settings-remote-control-section.component.ts index 3b69c0636..1b904650b 100644 --- a/apps/web/src/app/settings/settings-remote-control-section.component.ts +++ b/apps/web/src/app/settings/settings-remote-control-section.component.ts @@ -7,20 +7,20 @@ import { } from '@angular/core'; import { FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { MatTooltipModule } from '@angular/material/tooltip'; import { TranslateModule } from '@ngx-translate/core'; import { QRCodeComponent } from 'angularx-qrcode'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @Component({ selector: 'app-settings-remote-control-section', imports: [ MatButtonModule, - MatFormFieldModule, + AppFormFieldModule, MatIconModule, MatInputModule, MatSlideToggleModule, diff --git a/apps/web/src/app/settings/settings-tmdb-section.component.ts b/apps/web/src/app/settings/settings-tmdb-section.component.ts index 428893053..140f34d9a 100644 --- a/apps/web/src/app/settings/settings-tmdb-section.component.ts +++ b/apps/web/src/app/settings/settings-tmdb-section.component.ts @@ -8,12 +8,12 @@ import { } from '@angular/core'; import { FormControl, FormGroup, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSlideToggleModule } from '@angular/material/slide-toggle'; import { TranslateModule } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { TmdbApiService, TmdbCacheService } from '@iptvnator/services'; import type { TmdbCacheStats } from '@iptvnator/shared/interfaces'; import { markSectionForCheckOnFormEvents } from './settings-section-form-render'; @@ -24,7 +24,7 @@ type TmdbKeyTestState = 'idle' | 'testing' | 'success' | 'error'; selector: 'app-settings-tmdb-section', imports: [ MatButtonModule, - MatFormFieldModule, + AppFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, diff --git a/apps/web/src/m3-theme.scss b/apps/web/src/m3-theme.scss index acd62acd0..f7e189a8f 100644 --- a/apps/web/src/m3-theme.scss +++ b/apps/web/src/m3-theme.scss @@ -278,9 +278,9 @@ html { } // ─── Global refined outlined inputs ────────────────────────────────────────── -// Applied app-wide AFTER mat.all-component-themes so these overrides win the -// cascade. Pairs with MAT_FORM_FIELD_DEFAULT_OPTIONS (outline + dynamic) set -// in app.config.ts. +// Applied app-wide AFTER the component themes so these overrides win the +// cascade. Pairs with the outline + dynamic defaults that AppFormFieldModule +// (libs/ui/components, form-field) gives every form field. $app-refined-input-density: mat.define-theme(( density: (scale: -2), )); diff --git a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts index 0b86cb5af..e6f68bdf7 100644 --- a/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts +++ b/libs/playlist/import/feature/src/lib/auto-import/auto-import.component.ts @@ -9,10 +9,10 @@ import { import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'; import { FormControl, ReactiveFormsModule } from '@angular/forms'; import { MatButtonModule } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIcon } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { detectProviderImportCandidates, ProviderImportCandidate, @@ -150,7 +150,7 @@ function maskUrlQueryPasswords(url: string): string { changeDetection: ChangeDetectionStrategy.OnPush, imports: [ MatButtonModule, - MatFormFieldModule, + AppFormFieldModule, MatIcon, MatInputModule, ReactiveFormsModule, diff --git a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts index 1a19d0403..40d52ef2e 100644 --- a/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts +++ b/libs/playlist/import/feature/src/lib/stalker-portal-import/stalker-portal-import.component.ts @@ -18,12 +18,12 @@ import { } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; import { MatCheckboxModule } from '@angular/material/checkbox'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIcon } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatSnackBar } from '@angular/material/snack-bar'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { legacyTransformStalkerPortalUrl, @@ -66,7 +66,7 @@ interface StalkerSettledIdentity { imports: [ FormsModule, MatCheckboxModule, - MatFormFieldModule, + AppFormFieldModule, MatIcon, MatIconButton, MatInputModule, diff --git a/libs/playlist/import/feature/src/lib/text-import/text-import.component.spec.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.spec.ts index 49a1256f7..923c54335 100644 --- a/libs/playlist/import/feature/src/lib/text-import/text-import.component.spec.ts +++ b/libs/playlist/import/feature/src/lib/text-import/text-import.component.spec.ts @@ -3,6 +3,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; import { TranslateModule } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { MockModule } from 'ng-mocks'; import { TextImportComponent } from './text-import.component'; @@ -14,6 +15,9 @@ describe('TextImportComponent', () => { TestBed.configureTestingModule({ imports: [ TextImportComponent, + // The form field and its input are mocked together: a real + // MatFormField would look for the mocked input's state. + MockModule(AppFormFieldModule), MockModule(MatInputModule), MockModule(FormsModule), MockModule(ReactiveFormsModule), diff --git a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts index f767cffb7..6864285d1 100644 --- a/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts +++ b/libs/playlist/import/feature/src/lib/text-import/text-import.component.ts @@ -12,13 +12,19 @@ import { } from '@angular/forms'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; @Component({ selector: 'app-text-import', templateUrl: './text-import.component.html', styleUrls: ['./text-import.component.scss'], changeDetection: ChangeDetectionStrategy.OnPush, - imports: [MatInputModule, ReactiveFormsModule, TranslatePipe], + imports: [ + AppFormFieldModule, + MatInputModule, + ReactiveFormsModule, + TranslatePipe, + ], }) export class TextImportComponent { @Output() textAdded = new EventEmitter(); diff --git a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts index 59338b8bf..bade04c31 100644 --- a/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts +++ b/libs/playlist/import/feature/src/lib/url-upload/url-upload.component.ts @@ -11,9 +11,9 @@ import { ReactiveFormsModule, Validators, } from '@angular/forms'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { RuntimeCapabilitiesService } from '@iptvnator/services'; @Component({ @@ -21,7 +21,7 @@ import { RuntimeCapabilitiesService } from '@iptvnator/services'; templateUrl: './url-upload.component.html', changeDetection: ChangeDetectionStrategy.OnPush, imports: [ - MatFormFieldModule, + AppFormFieldModule, MatInputModule, ReactiveFormsModule, TranslatePipe, diff --git a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts index ca7c148f9..514b9e90a 100644 --- a/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts +++ b/libs/playlist/import/feature/src/lib/xtream-code-import/xtream-code-import.component.ts @@ -16,11 +16,11 @@ import { Validators, } from '@angular/forms'; import { MatIconButton } from '@angular/material/button'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIcon } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { Store } from '@ngrx/store'; import { TranslatePipe } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { createXtreamConnectionTestState } from '@iptvnator/services'; import { PasswordVisibilityToggleDirective } from '@iptvnator/ui/components/password-visibility-toggle'; @@ -50,7 +50,7 @@ function xtreamServerUrlValidator( @Component({ imports: [ FormsModule, - MatFormFieldModule, + AppFormFieldModule, MatIcon, MatIconButton, MatInputModule, diff --git a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts index 0f68e88ba..04201a714 100644 --- a/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts +++ b/libs/playlist/shared/ui/src/lib/recent-playlists/playlist-info/playlist-info.component.ts @@ -28,6 +28,7 @@ import { MatSnackBar } from '@angular/material/snack-bar'; import { MatTooltip } from '@angular/material/tooltip'; import { Store } from '@ngrx/store'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; +import { AppFormFieldModule } from '@iptvnator/ui/components/form-field'; import { EpgRuntimeBridgeService } from '@iptvnator/epg/data-access'; import { PlaylistActions } from '@iptvnator/m3u-state'; import { firstValueFrom } from 'rxjs'; @@ -168,6 +169,7 @@ type DesktopFileSaveBridge = Pick< providers: [DatePipe], changeDetection: ChangeDetectionStrategy.OnPush, imports: [ + AppFormFieldModule, ClipboardModule, MatButton, MatCheckboxModule, diff --git a/libs/ui/components/src/index.ts b/libs/ui/components/src/index.ts index df0b14ba3..6a9d99d97 100644 --- a/libs/ui/components/src/index.ts +++ b/libs/ui/components/src/index.ts @@ -10,6 +10,7 @@ export * from './lib/confirm-dialog/dialog.service'; export * from './lib/content-hero/content-hero.component'; export * from './lib/expandable-text/expandable-text.component'; export * from './lib/external-playback-dock/external-playback-dock.component'; +export * from './lib/form-field/app-form-field.module'; export * from './lib/portal-detail-shell/content-about.component'; export * from './lib/portal-detail-shell/detail-template.directives'; export * from './lib/portal-detail-shell/portal-detail-shell.component'; diff --git a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts index bc0e423c8..27b6e21ff 100644 --- a/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts +++ b/libs/ui/components/src/lib/channel-list-container/epg-mapping-dialog/epg-mapping-dialog.component.ts @@ -14,11 +14,11 @@ import { MatDialogModule, MatDialogRef, } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatSnackBar } from '@angular/material/snack-bar'; +import { AppFormFieldModule } from '../../form-field/app-form-field.module'; import { EpgRuntimeBridgeService, EpgService } from '@iptvnator/epg/data-access'; import { TranslatePipe, TranslateService } from '@ngx-translate/core'; import { debounceTime, from, Subject, switchMap } from 'rxjs'; @@ -46,7 +46,7 @@ const SEARCH_MIN_CHARS = 2; FormsModule, MatButtonModule, MatDialogModule, - MatFormFieldModule, + AppFormFieldModule, MatIconModule, MatInputModule, MatProgressSpinnerModule, diff --git a/libs/ui/components/src/lib/form-field/app-form-field.module.spec.ts b/libs/ui/components/src/lib/form-field/app-form-field.module.spec.ts new file mode 100644 index 000000000..8797e8f52 --- /dev/null +++ b/libs/ui/components/src/lib/form-field/app-form-field.module.spec.ts @@ -0,0 +1,54 @@ +import { Component } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { AppFormFieldModule } from './app-form-field.module'; + +@Component({ + imports: [AppFormFieldModule, MatInputModule], + template: ` + + Name + + Short + + `, +}) +class WithDefaultsComponent {} + +@Component({ + imports: [MatFormFieldModule, MatInputModule], + template: ` + + Name + + + `, +}) +class WithoutDefaultsComponent {} + +function formField(component: typeof WithDefaultsComponent): HTMLElement { + const fixture = TestBed.createComponent(component); + fixture.detectChanges(); + return fixture.nativeElement.querySelector('mat-form-field'); +} + +describe('AppFormFieldModule', () => { + it('gives a form field the outlined appearance and dynamic subscript', () => { + const field = formField(WithDefaultsComponent); + + expect(field.classList).toContain('mat-form-field-appearance-outline'); + expect(field.querySelector('.mdc-notched-outline')).not.toBeNull(); + // Dynamic sizing reserves no empty subscript row below the field. + expect( + field.querySelector('.mat-mdc-form-field-subscript-dynamic-size') + ).not.toBeNull(); + }); + + it('is the only source of those defaults: Material alone renders filled', () => { + const field = formField(WithoutDefaultsComponent); + + expect(field.classList).toContain('mat-form-field-appearance-fill'); + expect(field.querySelector('.mdc-notched-outline')).toBeNull(); + }); +}); diff --git a/libs/ui/components/src/lib/form-field/app-form-field.module.ts b/libs/ui/components/src/lib/form-field/app-form-field.module.ts new file mode 100644 index 000000000..cc6f754be --- /dev/null +++ b/libs/ui/components/src/lib/form-field/app-form-field.module.ts @@ -0,0 +1,49 @@ +import { Directive, NgModule } from '@angular/core'; +import { + MAT_FORM_FIELD_DEFAULT_OPTIONS, + MatFormFieldDefaultOptions, + MatFormFieldModule, +} from '@angular/material/form-field'; + +/** The app-wide form field: outlined, with hints and errors sized as needed. */ +export const APP_FORM_FIELD_DEFAULTS: MatFormFieldDefaultOptions = { + appearance: 'outline', + subscriptSizing: 'dynamic', +}; + +/** + * Gives every `` the app defaults through its own element + * injector, which `MatFormField` reads before any environment injector. + * + * Provided at the root, the defaults were a static import of + * `@angular/material/form-field` in `app.config.ts`, and one eager import of + * any export puts a module's whole bundler chunk on the initial path: 54 KB + * of form-field code for an app whose every form field renders in a lazy + * route or dialog. A route-level provider would miss the dialogs that root + * services open, so the defaults travel with the element instead. + */ +@Directive({ + // The directive must match the Material element itself: an attribute + // would have to be added to every form field by hand. + // eslint-disable-next-line @angular-eslint/directive-selector + selector: 'mat-form-field', + providers: [ + { + provide: MAT_FORM_FIELD_DEFAULT_OPTIONS, + useValue: APP_FORM_FIELD_DEFAULTS, + }, + ], +}) +export class AppFormFieldDefaultsDirective {} + +/** + * Import this, not `MatFormFieldModule`, wherever a template renders + * ``; it exports Material's form field together with the + * defaults directive. `pnpm run styles:form-field-defaults:validate` (CI) + * rejects a component that renders a form field without it. + */ +@NgModule({ + imports: [MatFormFieldModule, AppFormFieldDefaultsDirective], + exports: [MatFormFieldModule, AppFormFieldDefaultsDirective], +}) +export class AppFormFieldModule {} diff --git a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts index 42fb878d9..184addf4b 100644 --- a/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts +++ b/libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts @@ -16,10 +16,10 @@ import { MatDialogModule, MatDialogRef, } from '@angular/material/dialog'; -import { MatFormFieldModule } from '@angular/material/form-field'; import { MatIconModule } from '@angular/material/icon'; import { MatInputModule } from '@angular/material/input'; import { TranslatePipe } from '@ngx-translate/core'; +import { AppFormFieldModule } from '../form-field/app-form-field.module'; import { createParentalLockPinThrottle, isValidParentalLockPin, @@ -66,7 +66,7 @@ export interface ParentalLockPinDialogData { FormsModule, MatButtonModule, MatDialogModule, - MatFormFieldModule, + AppFormFieldModule, MatIconModule, MatInputModule, TranslatePipe, diff --git a/package.json b/package.json index 023cb8546..a3ef63593 100644 --- a/package.json +++ b/package.json @@ -62,6 +62,9 @@ "a11y:icon-buttons:test": "node --test tools/nx/check-icon-button-names.test.mjs", "a11y:icon-buttons:check": "node tools/nx/check-icon-button-names.mjs", "a11y:icon-buttons:validate": "pnpm run a11y:icon-buttons:test && pnpm run a11y:icon-buttons:check", + "styles:form-field-defaults:test": "node --test tools/nx/check-form-field-defaults.test.mjs", + "styles:form-field-defaults:check": "node tools/nx/check-form-field-defaults.mjs", + "styles:form-field-defaults:validate": "pnpm run styles:form-field-defaults:test && pnpm run styles:form-field-defaults:check", "e2e:task-graphs:test": "node --test tools/nx/check-e2e-task-graphs.test.mjs", "e2e:task-graphs:check": "node tools/nx/check-e2e-task-graphs.mjs", "e2e:task-graphs:validate": "pnpm run e2e:task-graphs:test && pnpm run e2e:task-graphs:check", diff --git a/tools/nx/check-form-field-defaults.mjs b/tools/nx/check-form-field-defaults.mjs new file mode 100644 index 000000000..e74c1a17d --- /dev/null +++ b/tools/nx/check-form-field-defaults.mjs @@ -0,0 +1,239 @@ +import { execFileSync } from 'node:child_process'; +import { readFile } from 'node:fs/promises'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + parseTemplate, + TmplAstRecursiveVisitor, + tmplAstVisitAll, +} from '@angular/compiler'; +import ts from 'typescript'; + +import { isScanned } from './check-theme-references.mjs'; + +/** + * The app-wide form-field defaults (outlined, dynamic subscript) are not a + * root provider: that import put the whole form-field chunk on the initial + * path. `AppFormFieldModule` carries them on the element instead, so a + * component that renders `` without importing it silently + * gets Material's filled look. This guard requires the import. + */ + +/** Angular renderer sources whose components can render a form field. */ +const SCANNED_PATHSPECS = [ + 'apps/web/*.ts', + 'apps/remote-control-web/*.ts', + 'libs/*.ts', +]; + +export const DEFAULTS_MODULE = 'AppFormFieldModule'; +export const DEFAULTS_ALIAS = '@iptvnator/ui/components/form-field'; +const DEFAULTS_SOURCE = /(^|\/)app-form-field\.module$/; + +const FORM_FIELD_ELEMENT = 'mat-form-field'; + +/** Identifiers listed in an `imports: [...]` array, nested arrays included. */ +function importIdentifiers(expression) { + if (ts.isArrayLiteralExpression(expression)) { + return expression.elements.flatMap(importIdentifiers); + } + return ts.isIdentifier(expression) ? [expression.text] : []; +} + +function propertyNamed(object, name) { + return object.properties.find( + (property) => + ts.isPropertyAssignment(property) && + (ts.isIdentifier(property.name) || + ts.isStringLiteral(property.name)) && + property.name.text === name + ); +} + +function stringValue(property) { + const value = property?.initializer; + return value && + (ts.isStringLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)) + ? value.text + : undefined; +} + +/** + * The `@Component({...})` declarations of a TypeScript source: class name, + * the line its decorator starts on, inline `template` or `templateUrl`, and + * the identifiers of its `imports` array. Whether the source binds `AppFormFieldModule` from its + * module is reported alongside, since a same-named local class would not + * carry the defaults. + */ +export function componentDeclarations(source, file) { + const sourceFile = ts.createSourceFile( + file, + source, + ts.ScriptTarget.Latest, + true + ); + const components = []; + let bindsDefaultsModule = false; + + const visit = (node) => { + if ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + (node.moduleSpecifier.text === DEFAULTS_ALIAS || + DEFAULTS_SOURCE.test(node.moduleSpecifier.text)) && + node.importClause?.namedBindings && + ts.isNamedImports(node.importClause.namedBindings) && + node.importClause.namedBindings.elements.some( + (element) => element.name.text === DEFAULTS_MODULE + ) + ) { + bindsDefaultsModule = true; + } + if ( + ts.isDecorator(node) && + ts.isCallExpression(node.expression) && + ts.isIdentifier(node.expression.expression) && + node.expression.expression.text === 'Component' && + ts.isClassDeclaration(node.parent) + ) { + const [metadata] = node.expression.arguments; + if (metadata && ts.isObjectLiteralExpression(metadata)) { + const importsProperty = propertyNamed(metadata, 'imports'); + components.push({ + className: node.parent.name?.text ?? '(anonymous)', + line: + sourceFile.getLineAndCharacterOfPosition( + node.parent.getStart(sourceFile) + ).line + 1, + template: stringValue(propertyNamed(metadata, 'template')), + templateUrl: stringValue( + propertyNamed(metadata, 'templateUrl') + ), + imports: importsProperty + ? importIdentifiers(importsProperty.initializer) + : [], + }); + } + } + ts.forEachChild(node, visit); + }; + visit(sourceFile); + return { components, bindsDefaultsModule }; +} + +class FormFieldVisitor extends TmplAstRecursiveVisitor { + found = false; + + visitElement(element) { + if (element.name === FORM_FIELD_ELEMENT) this.found = true; + super.visitElement(element); + } +} + +/** Whether a template renders `` anywhere, control flow included. */ +export function templateRendersFormField(template, file) { + const parsed = parseTemplate(template, file, { + preserveWhitespaces: true, + }); + if (parsed.errors?.length) { + throw new Error( + `${file}: cannot parse template: ${parsed.errors[0].msg}` + ); + } + const visitor = new FormFieldVisitor(); + tmplAstVisitAll(visitor, parsed.nodes); + return visitor.found; +} + +/** + * Components of one source that render a form field without importing the + * defaults module. `readTemplate` resolves a `templateUrl` to its text. + */ +export async function findFormFieldsWithoutDefaults( + file, + source, + readTemplate +) { + const { components, bindsDefaultsModule } = componentDeclarations( + source, + file + ); + const findings = []; + for (const component of components) { + const template = + component.template ?? + (component.templateUrl + ? await readTemplate(component.templateUrl) + : undefined); + if (template === undefined) continue; + if (!templateRendersFormField(template, file)) continue; + if ( + bindsDefaultsModule && + component.imports.includes(DEFAULTS_MODULE) + ) { + continue; + } + findings.push({ + file, + line: component.line, + className: component.className, + }); + } + return findings; +} + +/** Tracked renderer sources the guard reads, at any depth. */ +export function listScannedFiles(rootDir) { + // No shell: `cmd.exe` treats single quotes as literal characters, so a + // POSIX-quoted pathspec reaches git intact on Windows and matches nothing. + return execFileSync('git', ['ls-files', ...SCANNED_PATHSPECS], { + cwd: rootDir, + encoding: 'utf8', + maxBuffer: 32 * 1024 * 1024, + }) + .trim() + .split('\n') + .filter(Boolean) + .filter(isScanned); +} + +const isMain = + process.argv[1] && + path.resolve(process.argv[1]) === + path.resolve(fileURLToPath(import.meta.url)); + +if (isMain) { + const rootDir = process.cwd(); + const files = listScannedFiles(rootDir); + + const findings = []; + for (const file of files) { + const source = await readFile(path.join(rootDir, file), 'utf8'); + if ( + !source.includes(FORM_FIELD_ELEMENT) && + !source.includes('templateUrl') + ) { + continue; + } + findings.push( + ...(await findFormFieldsWithoutDefaults(file, source, (url) => + readFile(path.resolve(rootDir, path.dirname(file), url), 'utf8') + )) + ); + } + + if (findings.length > 0) { + console.error( + `Components that render without ${DEFAULTS_MODULE} (from ${DEFAULTS_ALIAS}), so their fields lose the app defaults (outlined, dynamic subscript). Import it in place of MatFormFieldModule:` + ); + for (const { file, line, className } of findings) { + console.error(`- ${file}:${line} ${className}`); + } + process.exitCode = 1; + } else { + console.log( + `Checked ${files.length} renderer sources; every component rendering imports ${DEFAULTS_MODULE}.` + ); + } +} diff --git a/tools/nx/check-form-field-defaults.test.mjs b/tools/nx/check-form-field-defaults.test.mjs new file mode 100644 index 000000000..16fdab1c4 --- /dev/null +++ b/tools/nx/check-form-field-defaults.test.mjs @@ -0,0 +1,218 @@ +import assert from 'node:assert/strict'; +import { execFileSync } from 'node:child_process'; +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { test } from 'node:test'; + +import { + componentDeclarations, + findFormFieldsWithoutDefaults, + listScannedFiles, + templateRendersFormField, +} from './check-form-field-defaults.mjs'; + +const lines = (...rows) => rows.join('\n'); + +const component = ({ + name = 'FieldComponent', + imports = ['AppFormFieldModule'], + template, + templateUrl, + importLine = "import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';", +}) => + lines( + "import { Component } from '@angular/core';", + importLine, + '', + '@Component({', + ` selector: 'app-field',`, + ` imports: [${imports.join(', ')}],`, + template !== undefined + ? ` template: \`${template}\`,` + : ` templateUrl: '${templateUrl}',`, + '})', + `export class ${name} {}` + ); + +const noTemplates = () => { + throw new Error('no templateUrl expected'); +}; + +test('accepts a form field in a component that imports the defaults module', async () => { + const source = component({ + template: '', + }); + assert.deepEqual( + await findFormFieldsWithoutDefaults( + 'a.component.ts', + source, + noTemplates + ), + [] + ); +}); + +test('reports the line of the @Component decorator when the defaults module is missing', async () => { + const source = component({ + imports: ['MatFormFieldModule'], + importLine: + "import { MatFormFieldModule } from '@angular/material/form-field';", + template: '', + }); + assert.deepEqual( + await findFormFieldsWithoutDefaults( + 'a.component.ts', + source, + noTemplates + ), + [{ file: 'a.component.ts', line: 4, className: 'FieldComponent' }] + ); +}); + +test('a same-named class from elsewhere does not count as the defaults module', async () => { + const source = component({ + importLine: "import { AppFormFieldModule } from './local-module';", + template: '', + }); + assert.equal( + ( + await findFormFieldsWithoutDefaults( + 'a.component.ts', + source, + noTemplates + ) + ).length, + 1 + ); +}); + +test('a relative import of the module source counts inside its own library', async () => { + const source = component({ + importLine: + "import { AppFormFieldModule } from '../../form-field/app-form-field.module';", + template: '', + }); + assert.deepEqual( + await findFormFieldsWithoutDefaults( + 'a.component.ts', + source, + noTemplates + ), + [] + ); +}); + +test('reads templateUrl templates and sees form fields inside control flow', async () => { + const source = component({ + imports: ['MatInputModule'], + importLine: "import { MatInputModule } from '@angular/material/input';", + templateUrl: './a.component.html', + }); + const urls = []; + const findings = await findFormFieldsWithoutDefaults( + 'dir/a.component.ts', + source, + async (url) => { + urls.push(url); + return lines( + '@if (editing()) {', + ' ', + ' ', + ' ', + '}' + ); + } + ); + assert.deepEqual(urls, ['./a.component.html']); + assert.equal(findings.length, 1); +}); + +test('ignores components without a form field and classes without a template', async () => { + const source = lines( + component({ + imports: ['MatButtonModule'], + importLine: + "import { MatButtonModule } from '@angular/material/button';", + template: '', + }), + '@Injectable({ providedIn: "root" })', + 'export class PlainService {}' + ); + assert.deepEqual( + await findFormFieldsWithoutDefaults('a.ts', source, noTemplates), + [] + ); +}); + +test('templateRendersFormField does not match a form field inside a comment', () => { + assert.equal( + templateRendersFormField( + '', + 'a.html' + ), + false + ); + assert.equal( + templateRendersFormField( + '
', + 'a.html' + ), + true + ); +}); + +test('componentDeclarations flattens nested import arrays and keeps the template url', () => { + const { components, bindsDefaultsModule } = componentDeclarations( + lines( + "import { Component } from '@angular/core';", + "import { AppFormFieldModule } from '@iptvnator/ui/components/form-field';", + 'const SHARED = [AppFormFieldModule];', + '@Component({', + ' imports: [[MatInputModule, SHARED], AppFormFieldModule],', + " templateUrl: './x.html',", + '})', + 'export class XComponent {}' + ), + 'x.ts' + ); + assert.equal(bindsDefaultsModule, true); + assert.deepEqual(components, [ + { + className: 'XComponent', + line: 4, + template: undefined, + templateUrl: './x.html', + imports: ['MatInputModule', 'SHARED', 'AppFormFieldModule'], + }, + ]); +}); + +test('listScannedFiles returns tracked renderer sources and skips specs', async () => { + const root = await mkdtemp(path.join(os.tmpdir(), 'form-field-guard-')); + try { + await mkdir(path.join(root, 'libs/ui/src'), { recursive: true }); + await mkdir(path.join(root, 'apps/web/src'), { recursive: true }); + await writeFile(path.join(root, 'libs/ui/src/a.component.ts'), ''); + await writeFile(path.join(root, 'libs/ui/src/a.component.spec.ts'), ''); + await writeFile(path.join(root, 'apps/web/src/b.component.ts'), ''); + await writeFile(path.join(root, 'apps/web/src/untracked.ts'), ''); + execFileSync('git', ['init', '-q'], { cwd: root }); + execFileSync( + 'git', + [ + 'add', + 'libs/ui/src/a.component.ts', + 'libs/ui/src/a.component.spec.ts', + 'apps/web/src/b.component.ts', + ], + { cwd: root } + ); + assert.deepEqual(listScannedFiles(root).sort(), [ + 'apps/web/src/b.component.ts', + 'libs/ui/src/a.component.ts', + ]); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/tsconfig.base.json b/tsconfig.base.json index 01a5e5b29..f40422ba4 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -105,6 +105,9 @@ "@iptvnator/ui/components/confirm-dialog": [ "libs/ui/components/src/lib/confirm-dialog/dialog.service.ts" ], + "@iptvnator/ui/components/form-field": [ + "libs/ui/components/src/lib/form-field/app-form-field.module.ts" + ], "@iptvnator/ui/components/parental-lock-pin-dialog": [ "libs/ui/components/src/lib/parental-lock-pin-dialog/parental-lock-pin-dialog.component.ts" ],