articles

Web Theme Loader with Comprehensive Features and Minimum API Surface

You can use this simple, powerful and flexible Web theme loader in your Web app especially SPA+PWA to realize similar UX as seen in these prominent sites:

  1. Angular Material Doc
  2. PrimeNG
  3. PrimeVue
  4. DaisyUI

Web Sites and Apps that Use This ThemeLoader

MatSelectThemeMenuThemeMenuNG

Summary

The Web theme loader API exposes 3 contracts:

  1. static init() of themeLoader to be called during app startup.
  2. static loadTheme(picked: string | null, appColorsDir?: string | null) to be called when the app user picks one from available themes.
  3. static get selectedTheme(): string | null of themeLoader to give the URL of the selected theme, so GUI may display which theme is in-use.

Because the theme should be loaded at startup before the Web app rendering, the respective config must be loaded synchronously ASAP.

The GUI of theme selection is independent of the Web theme loader API. For example, in addition to Select and Menu for multiple themes, you may use Switch for switching between light and dark.

Remarks:


## Installation
1. Install [theme-loader-api](https://www.npmjs.com/package/theme-loader-api):

npm install theme-loader-api


## Integration
1. Call `ThemeLoader.loadTheme()` before the [bootstrap of the Web app](https://github.com/zijianhuang/DemoCoreWeb/blob/master/AngularHeroes/src/main.ts).
1. In the [UI component presenting the theme picker](https://github.com/zijianhuang/DemoCoreWeb/blob/master/AngularHeroes/src/app/theme-select.component.ts), convert the themes dictionary to an array which will be used to present the list. And call `ThemeLoader.loadTheme()` when the picker picks a theme.
1. Prepare [`siteconfig.js`](https://zijianhuang.github.io/DemoCoreWeb/react/conf/siteconfig.js) and add `<script src="conf/siteconfig.js"></script>` to [index.html](https://github.com/zijianhuang/DemoCoreWeb/blob/master/ReactHeroes/index.html) if you want flexibility after build and deployment. Or, simply provide constant THEME_CONFIG in app code.

### [Angular Example](https://github.com/zijianhuang/DemoCoreWeb/blob/master/AngularHeroes/)

 [main.ts](https://github.com/zijianhuang/DemoCoreWeb/blob/master/AngularHeroes/src/main.ts)
 ```ts
ThemeLoader.init();
bootstrapApplication(AppComponent, appConfig); 

theme-select.component.ts

	constructor() {
		this.themes = ThemeConfigConstants.themesDic ? Object.keys(ThemeConfigConstants.themesDic).map(k => {
			const c = ThemeConfigConstants.themesDic![k];
			const obj: ThemeDef = {
				display: c.display,
				filePath: k,
				dark: c.dark
			};
			return obj;
		}) : undefined;
	}

	themeSelectionChang(e: MatSelectChange) {
		ThemeLoader.loadTheme(e.value);
	}

theme-select.component.html

<mat-select #themeSelect (selectionChange)="themeSelectionChang($event)" [value]="currentTheme">
	@for (item of themes; track $index) {
	<mat-option [value]="item.filePath"></mat-option>
	}
</mat-select>

siteconfig.js

const THEME_CONFIG = {
	themesDic: {
		"assets/themes/azure-blue.css": { display: "Azure & Blue", dark: false },
		"assets/themes/rose-red.css": { display: "Roes & Red", dark: false },
		"assets/themes/magenta-violet.css": { display: "Magenta & Violet", dark: true },
		"assets/themes/cyan-orange.css": { display: "Cyan & Orange", dark: true }
	},
	themeLoaderSettings: {
		storageKey: 'app.theme',
		themeLinkId: 'theme',
		appColorsDir: 'conf/',
		appColorsLinkId: 'app-colors',
		colorsCss: 'colors.css',
		colorsDarkCss: 'colors-dark.css'
	}
}

index.html

    <script src="conf/siteconfig.js"></script>
</head>

React Example

main.tsx

ThemeLoader.init();

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(...

Home.tsx

	const themes = ThemeConfigConstants.themesDic ? Object.keys(ThemeConfigConstants.themesDic).map(k => {
		const c = ThemeConfigConstants.themesDic![k];
		const obj: ThemeDef = {
			display: c.display,
			filePath: k,
			dark: c.dark
		};
		return obj;
	}) : undefined;

	const [currentTheme, setCurrentTheme] = useState(() => ThemeLoader.selectedTheme ?? undefined);
	const handleChange = (event: React.ChangeEvent<HTMLSelectElement>) => {
		const v = event.target.value;
		setCurrentTheme(v);
		ThemeLoader.loadTheme(v);
	};

	return (
		<>
			<h1>React Heroes!</h1>
			<div>
				<label htmlFor="theme-select">Themes </label>
				<select
					id="theme-select"
					value={currentTheme ?? ""}
					onChange={handleChange}
				>
					{themes?.map((item) => (
						<option key={item.filePath} value={item.filePath}>
							{item.display}
						</option>
					))}
				</select>
			</div>

siteconfig.js

const THEME_CONFIG = {
	themesDic: {
		"assets/themes/light-theme.css": { display: "Light", dark: false },
		"assets/themes/dark-theme.css": { display: "Dark", dark: true },
		"assets/themes/pink-theme.css": { display: "Pink", dark: false }
	},
	themeLoaderSettings: {
		storageKey: 'app.theme',
		themeLinkId: 'theme',
		appColorsDir: 'conf/',
		appColorsLinkId: 'app-colors',
		colorsCss: 'colors.css',
		colorsDarkCss: 'colors-dark.css'
	}
}

index.html

    <script src="conf/siteconfig.js"></script>
</head>

Respect prefers-color-scheme

By default, this API will pick the first available theme in the dictionary during the first startup of the Web app, and use last pick afterward. If you want to respect prefers-color-scheme during the initial load of the Web app, you may use the following in the app’s bootstrap:

const isDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
var r = findFirstTheme(isDark);
if (r) {
	ThemeLoader.loadTheme(r.filePath);
}

platformBrowser().bootstrapModule(AppModule, { applicationProviders: [provideZoneChangeDetection()], })
	.catch(err => console.error(err));

function findFirstTheme(dark: boolean): { filePath: string; theme: ThemeValue } | undefined {
	const entry = Object.entries(ThemeConfigConstants.themesDic!).find(
		([, theme]) => theme.dark === dark
	);

	return entry ? { filePath: entry[0], theme: entry[1] } : undefined;
}

How About I18N and L10N?

The only things need to be translated is the display name of each theme.

Solution 1: No need for I18N and Use Icon To Represent Theme Impression

You may extend interface ThemeDef, and make it contain some meta info of generating SVG icons presenting respective theme. And the icons will be inline with the HTML template. Angular Material Components site uses this approach.

Or you may just hand-draw some SVG icons and linked them in the HTML template.

Solution 2: Create Dictionary in App Code

Depending the framework like Angular or the library like React, there could be a few ways to create a dictionary to lookup translations and create translations.

Solution 3: Post Build Processing

If you are using siteconfig.js, the JS file should not be included in the hash tables of the service worker for automatic app update.

In Angular, each locale has its own build, therefore, you may craft some post build script to inject the translated names into the siteconfig.js of each build.