Proper Angular Material V3 Theming

Discussão em 'Angular' iniciado por Naftis, Novembro 27, 2024.

  1. Naftis

    Naftis Guest

    I am trying to create an example Angular 19 app with Angular Material v3 having the following features:

    • custom light and dark themes;
    • toggle theme at runtime;
    • use color variants for primary, warn and the like as with the old color directive (a proper way for doing this in V3 is welcome, I fear it will imply a lot more themes...).

    I thus created a new Angular 19 app and added Angular Material via ng add @angular/material. You can find a full repro repository at https://github.com/Myrmex/angular-theming. The relevant code is reported below:

    (1) For styles, I ensured that no prebuilt theme is included in styles in my angular.json file and wrote this styles.scss:

    // styles.scss
    @use "@angular/material" as mat;

    @include mat.core();

    // create color configurations for light and dark themes
    $light-theme: mat.define-theme(
    (
    color: (
    theme-type: light,
    primary: mat.$blue-palette,
    ),
    )
    );

    $dark-theme: mat.define-theme(
    (
    color: (
    theme-type: dark,
    primary: mat.$blue-palette,
    ),
    )
    );

    // base styles
    html {
    color-scheme: light dark;
    }

    // default (light) theme
    :root,
    html:not(.dark-theme) {
    @include mat.all-component-themes($light-theme);
    & {
    color-scheme: light;
    }
    }

    // dark theme styles
    html.dark-theme {
    // @include mat.all-component-themes($dark-theme);
    @include mat.all-component-colors($dark-theme);
    & {
    color-scheme: dark;
    }
    }

    body {
    background: var(--mat-sys-surface);
    color: var(--mat-sys-on-surface);
    }

    @include mat.color-variants-backwards-compatibility($light-theme);
    @include mat.color-variants-backwards-compatibility($dark-theme);


    (2) I then added a theme service for toggling themes:

    // theme.service.ts
    import { Injectable, Inject, PLATFORM_ID } from '@angular/core';
    import { isPlatformBrowser } from '@angular/common';

    const THEME_KEY = 'app-theme-preference';

    @Injectable({
    providedIn: 'root',
    })
    export class ThemeService {
    private readonly _htmlElement?: HTMLHtmlElement;

    constructor(@Inject(PLATFORM_ID) private platformId: Object) {
    // ensure we only access document/window in browser environment
    if (isPlatformBrowser(this.platformId)) {
    this._htmlElement = document.documentElement as HTMLHtmlElement;
    }
    }

    /**
    * Initialize theme on app startup
    */
    public initializeTheme() {
    if (!isPlatformBrowser(this.platformId)) return;
    const savedTheme = this.getSavedTheme();
    const systemTheme = this.getSystemTheme();

    // Prefer saved theme, fall back to system theme
    const initialTheme = savedTheme || systemTheme;
    this.setTheme(initialTheme);

    // Listen for system theme changes
    const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
    mediaQuery.addEventListener('change', (e) => {
    if (!this.getSavedTheme()) {
    this.setTheme(e.matches ? 'dark' : 'light');
    }
    });
    }

    /**
    * Toggle between light and dark themes
    */
    public toggleTheme(): 'light' | 'dark' | null {
    if (!isPlatformBrowser(this.platformId)) return null;

    const currentTheme = this.getCurrentTheme();
    const newTheme = currentTheme === 'dark' ? 'light' : 'dark';
    this.setTheme(newTheme);
    return newTheme;
    }

    /**
    * Set a specific theme.
    * @param theme 'light' or 'dark'.
    */
    public setTheme(theme: 'light' | 'dark') {
    if (!isPlatformBrowser(this.platformId)) return;

    console.log('Setting theme:', theme);

    // Save theme preference
    localStorage.setItem(THEME_KEY, theme);

    // Set color-scheme on html element
    this._htmlElement?.setAttribute('color-scheme', theme);

    // Toggle theme-specific classes
    if (theme === 'dark') {
    console.log('Adding dark-theme class');
    this._htmlElement?.classList.add('dark-theme');
    this._htmlElement?.classList.remove('light-theme');
    } else {
    console.log('Adding light-theme class');
    this._htmlElement?.classList.add('light-theme');
    this._htmlElement?.classList.remove('dark-theme');
    }
    }
    /**
    * Get the current active theme
    * @returns 'light' or 'dark'.
    */
    public getCurrentTheme(): 'light' | 'dark' {
    if (!isPlatformBrowser(this.platformId)) return 'light';

    return this._htmlElement?.getAttribute('color-scheme') as 'light' | 'dark';
    }

    private getSystemTheme(): 'light' | 'dark' {
    return window.matchMedia('(prefers-color-scheme: dark)').matches
    ? 'dark'
    : 'light';
    }

    /**
    * Get saved theme preference from localStorage
    * @returns Saved theme or null.
    */
    private getSavedTheme(): 'light' | 'dark' | null {
    if (!isPlatformBrowser(this.platformId)) return null;

    const savedTheme = localStorage.getItem(THEME_KEY);
    return savedTheme === 'light' || savedTheme === 'dark' ? savedTheme : null;
    }

    /**
    * Set theme based on system preference
    */
    public setThemeFromSystemPreference() {
    if (!isPlatformBrowser(this.platformId)) return;

    const prefersDarkScheme = window.matchMedia('(prefers-color-scheme: dark)');
    const initialTheme = prefersDarkScheme.matches ? 'dark' : 'light';
    this.setTheme(initialTheme);

    // Use addEventListener instead of deprecated addListener
    prefersDarkScheme.addEventListener('change', (e) => {
    // Only change if no saved preference exists
    if (!this.getSavedTheme()) {
    this.setTheme(e.matches ? 'dark' : 'light');
    }
    });
    }
    }


    (3) Finally, I added a toggler component:

    // theme-toggle.component.ts
    import { Component, OnInit } from '@angular/core';
    import { MatIconModule } from '@angular/material/icon';
    import { MatButtonModule } from '@angular/material/button';

    import { ThemeService } from '../../services/theme.service';

    @Component({
    selector: 'app-theme-toggle',
    imports: [MatButtonModule, MatIconModule],
    templateUrl: './theme-toggle.component.html',
    styleUrl: './theme-toggle.component.scss',
    })
    export class ThemeToggleComponent implements OnInit {
    isDarkMode = false;

    constructor(private themeService: ThemeService) {}

    public ngOnInit() {
    this.themeService.initializeTheme();
    // set initial theme
    this.updateThemeMode();
    }

    public toggleTheme() {
    this.themeService.toggleTheme();
    this.updateThemeMode();
    }

    private updateThemeMode() {
    this.isDarkMode = this.themeService.getCurrentTheme() === 'dark';
    }
    }


    with its HTML template:

    <!-- theme-toggle.component.html -->
    <button
    type="button"
    mat-icon-button
    (click)="toggleTheme()"
    aria-label="Toggle theme"
    >
    <mat-icon>{{ isDarkMode ? "light_mode" : "dark_mode" }}</mat-icon>
    </button>


    This works, but I have some glitches:


    1. it seems that the default typography is not applied. Except when text is inside a button or other Material components, text appears with the default (Times New Roman) font and size, e.g. if I place a p element inside a mat-card. I tried to add typography via its simple font name, Roboto, by adding typography: Roboto as specified in the current theming guide; still, I get a compile error because here an object is expected, and I could find no example about this object. Further, I don't want to redefine typography, just ensure it gets applied.


    2. I need a quick and easy way to apply colors, so I added the compatibility include (see this question about color variants), also because I have many Angular libraries just using mat-primary and similar classes for those variants. Yet, this does not seem to work e.g. for button background colors: I get only a different foreground for the button's labels.

    As an example, here is my HTML in app.component.html:

    <article>
    <div>
    <app-theme-toggle />
    </div>
    <hr />
    <mat-card>
    <mat-card-header>
    <!-- this has the expected typography -->
    <mat-card-title>Custom Theming with Toggle</mat-card-title>
    </mat-card-header>
    <mat-card-content>
    <!-- this appears in Times... -->
    <p>This is an example.</p>
    <div class="form-row">
    <!-- this has default background and only changes text foreground... -->
    <button type="button" mat-raised-button class="mat-primary">
    primary
    </button>
    <!-- as above... -->
    <button type="button" mat-raised-button class="mat-warn">
    warn
    </button>
    </div>
    </mat-card-content>
    </mat-card>
    </article>

    Continue reading...

Compartilhe esta Página