Monday, February 19, 2018

Building a Top-Level Style Switcher (Themes) for Angular

Typically, if your Angular application has CSS styling that is conceptually "shared" or does not belong to a particular component (we're talking Angular 2+ here), you'll have one or more top-level .css files referenced with non-encapsulated class definitions. In an angular-cli build, these get listed in the "styles" array in .angular-cli.json. If you want your application to support different "themes", you might want to swap out one or more of these top-level stylesheets. It turns out that this is not super-easy to do.

One approach to theming that works is to have a component-per-theme with non-encapsulated styles (encapsulation: ViewEncapsulation.None). Such components have no HTML template markup, and their only purpose in life is to sit at or near the top of the application and get dynamically get turned on and off as the theme is changed.

A simple theme-switcher component with a dropdown selector might look like this:

<select [(ngModel)]="selectedStyle">
  <option value="redOnGreen">Red on Green</option>
  <option value="greenOnRed">Green on Red</option>
</select>
<app-red-on-green-style *ngIf="selectedStyle === 'redOnGreen'">
</app-red-on-green-style>
<app-green-on-red-style *ngIf="selectedStyle === 'greenOnRed'">
</app-green-on-red-style>

Each theme component has the same class names in its corresponding styleUrl list. For a "red-on-green" theme:

.title {
  background-color: green;
  color: red;
}

.subtitle {
  background-color: green;
  color: red;
}

And its inverse:

.title {
  background-color: red;
  color: green;
}

.subtitle {
  background-color: red;
  color: green;
}

Placing this at the top of the demo application, you will see this the first time you open it:



And after changing the selector:



The problem comes when you try to change it back. Turns out, you can't. That's because when a component with ViewEncapsulation.None first gets initialized, its styles get injected into the of the page, and they never go away. So in this case, the second component's styles are sitting last in the , and they permanently override the first component's, regardless of which one is "on". And the situation is made all the bleaker by the fact that the Angular-injected style tags don't have a "name" attribute or anything else that would help you, say, find and remove them.

The first step in my approach to address this issue is to augment the CSS for each theme with a dummy class that uniquely identifies it. For the red-on-green theme:

.red-on-green {}

And green-on-red:

.green-on-red {}

Now, with a bit of DOM-programming determination, we can manipulate the style nodes in the head to get the effect that we want. I wrapped a quick-and-dirty implementation in an Angular service:

import { Injectable, Inject } from '@angular/core';
import { DOCUMENT } from '@angular/platform-browser';

@Injectable()
export class StyleApplicationService {

  constructor(@Inject(DOCUMENT) private document: any) {
  }

  public apply(styleName: string): void {
    let foundStyleSheet;
    const fullStyleName = '.' + styleName;
    for (const styleSheet of this.document.styleSheets) {
      if (styleSheet.cssRules && styleSheet.cssRules.length) {
        const rule = styleSheet.cssRules[0];
        if (rule.selectorText === fullStyleName) {
          foundStyleSheet = styleSheet;
          break;
        }
      }
    }
    if (foundStyleSheet) {
      const child = this.document.head.removeChild(foundStyleSheet.ownerNode);
      if (child) {
        this.document.head.appendChild(child);
      }
    }
  }
}

This apply() method of this service surfs the document's styleSheets looking for the one whose 0th rule matches the given theme name. Then it removes the corresponding node and re-appends it, thus making it appear last and giving it the winning set of class definitions. Each theme component then injects this service and calls apply() passing the theme name in ngAfterViewInit. For example:


import { Component, ViewEncapsulation, AfterViewInit } from '@angular/core';
import { StyleApplicationService } from '../style-application.service';

@Component({
  selector: 'app-red-on-green-style',
  template: '',
  styleUrls: ['./red-on-green-style.component.css'],
  encapsulation: ViewEncapsulation.None
})
export class RedOnGreenStyleComponent implements AfterViewInit {
  constructor(private styleApplier: StyleApplicationService) {
  }
  
  ngAfterViewInit(): void {
    this.styleApplier.apply('red-on-green');
  }
}

Ideally, Angular would support this scenario better. Perhaps Material themes (which are now kind-of limited to colors) will expand to address more complete CSS re-theming of Angular applications.

No comments: