@rb-mwindh/ngx-theme-manager
Version:
Angular component to switch between different theming stylesheets
682 lines (674 loc) • 25 kB
JavaScript
import * as i0 from '@angular/core';
import { Injectable, Inject, InjectionToken, Optional } from '@angular/core';
import { Subject, share, takeUntil, distinctUntilChanged, tap, map, filter, timer, debounceTime, takeWhile, take, skipWhile } from 'rxjs';
import { DOCUMENT } from '@angular/common';
import * as i1 from '@angular/cdk/observers';
import * as i2 from '@angular/router';
/**
* RbStorageService is a service that provides an observable stream of storage change events.
*
* @internal
* @group Services
*/
class StorageService {
#destroy$;
/**
* The browser's local storage object.
*
* @readonly
* @private
*/
#storage;
/**
* The browser's window object.
*
* @readonly
* @private
*/
#window;
/**
* The Subject receiving the storage change events.
*
* @readonly
* @private
*/
#changes$;
/**
* Creates an instance of RbStorageService.
*
* @param {Document} document - the document object provided by Angular's DI.
*/
constructor(document) {
this.#destroy$ = new Subject();
/**
* The Subject receiving the storage change events.
*
* @readonly
* @private
*/
this.#changes$ = new Subject();
/**
* The observable stream of storage change events, shared among subscribers.
*
* @readonly
*/
this.changes$ = this.#changes$.pipe(share(), takeUntil(this.#destroy$));
this.#window = document.parentWindow || document.defaultView;
this.#storage = this.#window.localStorage;
this.#window.addEventListener('storage', this.#storageEventListener.bind(this));
}
/**
* An Angular lifecycle hook that removes the storage event listener.
*
* @internal
*/
ngOnDestroy() {
this.#window.removeEventListener('storage', this.#storageEventListener.bind(this));
this.#destroy$.next();
this.#destroy$.complete();
}
/**
* Sets a key-value pair in the local storage and emits a change event.
*
* @param {string} key - The key to set
* @param {string} newValue - The value to set
*/
setItem(key, newValue) {
const oldValue = this.#storage.getItem(key);
this.#storage.setItem(key, newValue);
this.#changes$.next({ key, oldValue, newValue });
}
/**
* Gets the value of a key in the local storage.
*
* @param {string} key - The key to get the value of
* @returns {(string | null)} The value of the key or null if it does not exist
*/
getItem(key) {
return this.#storage.getItem(key);
}
/**
* Removes the value of a key from the local storage and emits a change event.
*
* @param {string} key - The key to remove
*/
removeItem(key) {
const oldValue = this.#storage.getItem(key);
this.#storage.removeItem(key);
this.#changes$.next({ key, oldValue, newValue: null });
}
/**
* Clears all keys from the local storage and emits a change event for each removed key.
*/
clear() {
const changes = [];
for (let i = 0; i < this.#storage.length; i++) {
const key = this.#storage.key(i); // I'm sure it's never null!
const oldValue = this.#storage.getItem(key);
changes.push({ key, oldValue, newValue: null });
}
this.#storage.clear();
changes.forEach((change) => this.#changes$.next(change));
}
/**
* The storage event handler that takes a native StorageEvent and emits a {@link StorageChangeEvent}.
*
* @param {StorageEvent} event - The native event to handle
* @private
*/
#storageEventListener(event) {
if (event.storageArea != this.#storage) {
return;
}
const key = event.key;
const { oldValue, newValue } = event;
this.#changes$.next({ key, oldValue, newValue });
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: StorageService, deps: [{ token: DOCUMENT }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: StorageService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: StorageService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [{ type: Document, decorators: [{
type: Inject,
args: [DOCUMENT]
}] }] });
/**
* RbThemeRegistryService is an injectable service that allows for
* registering and unregistering themes, as well as providing access
* to the currently registered themes.
* It also provides an observable of themes that can be used
* to notify subscribers of changes to the registered themes.
*
* @internal
* @group Services
*/
class ThemeRegistryService {
constructor() {
/**
* The internal dictionary that holds the registered themes.
*
* @private
*/
this.#dictionary = new Map();
/**
* The subject that emits an updated array of registered themes whenever a change occurs.
*
* @private
*/
this.#themes$ = new Subject();
/**
* The observable that emits the collection of registered themes when it changes.
*/
this.themes$ = this.#themes$.pipe(share());
}
/**
* The internal dictionary that holds the registered themes.
*
* @private
*/
#dictionary;
/**
* The subject that emits an updated array of registered themes whenever a change occurs.
*
* @private
*/
#themes$;
/**
* Registers a new theme and emits the changed collection of themes.
*
* If a theme with the same ID is already registered,
* the registered theme will be merged with the new one.
*
* Themes without ID will be ignored.
*
* @param {Theme | undefined | null} theme - The new theme to register
*/
register(theme) {
if (!theme?.id) {
return;
}
const oldValue = this.#dictionary.get(theme.id) || {};
this.#dictionary.set(theme.id, { ...oldValue, ...theme });
this.#themes$.next([...this.#dictionary.values()]);
}
/**
* Removes the provided theme from the registry and emits the changed collection of themes.
*
* @param {Theme} theme - The theme to remove.
*/
unregister(theme) {
if (this.#dictionary.delete(theme?.id)) {
this.#themes$.next([...this.#dictionary.values()]);
}
}
/**
* Returns the collection of registered themes.
*
* @returns {Theme[]} A new array with all currently registered themes
* @remarks A new array is created every time this getter is called.
*/
get themes() {
return [...this.#dictionary.values()];
}
/**
* Returns the theme with the given ID, or null if no such theme is registered.
*
* @param {string | null} id - The ID of the theme to retrieve
*/
get(id) {
return this.#dictionary.get(id) || null;
}
/**
* Returns whether a theme with the given ID is currently registered.
*
* @param {string | null} id - The ID of the theme to check.
*/
has(id) {
return this.#dictionary.has(id);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeRegistryService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeRegistryService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeRegistryService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/**
* A service to track the currently active theme.
*
* @internal
* @group Services
*/
class ThemeTrackingService {
constructor() {
/**
* The subject that emits the next value set through {@link currentTheme}.
*
* @private
*/
this.#currentTheme$ = new Subject();
/**
* A private property to hold the current theme value.
*
* @private
*/
this.#currentTheme = null;
/**
* The observable that emits the current theme, shared among subscribers.
* This observable prevents emitting the same value twice in a row.
* It also updates the private #currentTheme property.
*/
this.currentTheme$ = this.#currentTheme$.pipe(distinctUntilChanged(), tap((theme) => (this.#currentTheme = theme)), share());
}
/**
* The subject that emits the next value set through {@link currentTheme}.
*
* @private
*/
#currentTheme$;
/**
* A private property to hold the current theme value.
*
* @private
*/
#currentTheme;
/**
* Set the current theme.
*
* @param {string | null} arg
*/
set currentTheme(arg) {
this.#currentTheme$.next(arg);
}
/**
* Get the current theme.
*
* @returns {string | null} The current theme
*/
get currentTheme() {
return this.#currentTheme;
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeTrackingService, deps: [], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeTrackingService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeTrackingService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}] });
/**
* A service that manages the activation and deactivation of themes.
*
* The service uses the `ContentObserver` service to observe changes in the `<head>` element
* and updates the {@link ThemeRegistryService internal theme registry}
* when new `<style>` elements are added to the DOM.
*
* Themes are identified by the `data-theme` attribute on the `<style>` element.
*
* The service provides a method `use` to activate a theme with a given ID
* and deactivate all other themes.
*
* @internal
* @group Services
*/
class ThemeStyleManagerService {
/**
* Creates a new instance.
*
* Subscribes to the `ContentObserver` to listen for new `<style>` elements
* added to the document head. If a new `<style>` element is added, the
* `#updateRegistry()` method is called.
*
* @param {ContentObserver} observer - The Angular ContentObserver service
* @param {ThemeRegistryService} themeRegistry - A service to register new themes
* @param {Document} document - A reference to the current document
*/
constructor(observer, themeRegistry, document) {
this.observer = observer;
this.themeRegistry = themeRegistry;
this.document = document;
this.observer
.observe(document.head)
.pipe(map((mutations) => mutations.some((mutation) => Array.from(mutation.addedNodes).some((node) => node.nodeName === 'STYLE'))), filter((newStyles) => !!newStyles))
.subscribe(() => this.#updateRegistry());
}
/**
* Activates the theme with the given ID and deactivates all other themes.
*
* @param {string} theme - The theme to activate
* @see turnOn
* @see turnOff
* @remarks A theme may consist of 1 or more `<style>` elements.
*/
use(theme) {
// INFO: [author: NWD8FE, since: 2023/01/26]
// running asynchronously, to give #updateRegistries
// the chance to run first.
timer(0).subscribe(() => {
const styles = this.#getAllThemeStyles();
styles.forEach((el) => {
const id = el.getAttribute('data-theme');
(theme === id ? turnOn : turnOff)(el);
});
});
}
/**
* Get all theme `<style>` elements in the document head.
*
* @private
*/
#getAllThemeStyles() {
return Array.from(this.document.head.querySelectorAll('style[data-theme]'));
}
/**
* Updates the internal theme registry.
*
* Identifies all `<style>` elements without the `data-no-theme` and `data-theme` attributes.
* Extracts the theme annotations from the elements' text content and applies the
* `data-theme` or `data-no-theme` attribute depending on the discovered theme id.
*
* @private
* @see extractThemeAnnotations
* @see applyThemeIdentifier
*/
#updateRegistry() {
Array.from(this.document.head.querySelectorAll('style:not([data-no-theme]):not([data-theme])'))
.map((el) => ({ el, meta: extractThemeAnnotations(el.textContent) }))
.forEach(({ el, meta }) => {
if (applyThemeIdentifier(el, meta?.id)) {
turnOff(el);
this.themeRegistry.register(meta);
}
});
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeStyleManagerService, deps: [{ token: i1.ContentObserver }, { token: ThemeRegistryService }, { token: DOCUMENT }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeStyleManagerService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeStyleManagerService, decorators: [{
type: Injectable,
args: [{
providedIn: 'root',
}]
}], ctorParameters: () => [{ type: i1.ContentObserver }, { type: ThemeRegistryService }, { type: Document, decorators: [{
type: Inject,
args: [DOCUMENT]
}] }] });
/**
* Applies a theme identifier to a `<style>` element.
*
* If the provided `id` is truthy, the element receives the attribute
* `data-theme` set to the given `id`. Otherwise, the element
* will get the attribute `data-no-theme` without any value.
*
* @param {HTMLStyleElement} el - The `<style>` element to apply the identifier to
* @param {string | undefined} id - The theme identifier
* @returns {boolean} true, if the style element belongs to a theme
* @group Functions
* @internal
*/
function applyThemeIdentifier(el, id) {
if (!!id) {
el.setAttribute('data-theme', id);
return true;
}
else {
el.toggleAttribute('data-no-theme', true);
return false;
}
}
/**
* Turn off a style element by setting its `media` attribute to `none`.
*
* @param {HTMLStyleElement} el - The style element to turn off.
* @group Functions
* @internal
*/
function turnOff(el) {
el.media = 'none';
}
/**
* Turn on a style element by removing its `media` attribute.
*
* @param {HTMLStyleElement} el - The style element to turn on.
* @group Functions
* @internal
*/
function turnOn(el) {
el.removeAttribute('media');
}
/**
* Extracts theme annotations from a given string.
*
* **Format:** `@@<annotationName> value` (until end of line)
*
* Possible annotation names:
* - `id`: a unique identifier for the theme
* - `displayName`: a human-readable name for the theme
* - `description`: a short description of the theme
* - `defaultTheme`: a boolean flag indicating if this is the default theme
*
* @param {string} s The string to extract annotations from.
* @returns {Theme | null} An object containing the extracted annotations, or null if no annotations were found.
* @group Functions
* @internal
*/
function extractThemeAnnotations(s) {
if (!s) {
return null;
}
const id = unwrap(/@\s+([^\r\n]+)$/m.exec(s));
if (!id) {
return null;
}
const displayName = unwrap(/@\s+([^\r\n]+)$/m.exec(s)) || id;
const description = unwrap(/@\s+([^\r\n]+)$/m.exec(s));
const defaultTheme = /@/m.test(s);
return { id, displayName, description, defaultTheme };
}
/**
* Unwrap a match from a regular expression, returning the first captured group as a string.
*
* @param {RegExpExecArray} match - The match to unwrap.
* @group Functions
* @internal
*/
function unwrap(match) {
return (match && match[1] && match[1].trim()) || undefined;
}
const PREFIX = 'ngx-theme-manager';
/**
* @group InjectionToken
*/
const STORAGE_KEY = new InjectionToken(`${PREFIX}/STORAGE_KEY`, {
factory: () => `${PREFIX}/current-theme`,
});
/**
* @group InjectionToken
*/
const QUERY_PARAM = new InjectionToken(`${PREFIX}/QUERY_PARAM`, {
factory: () => `theme`,
});
/**
* Service for managing and switching between different themes in an application.
*
* @group Services
* @group Public API
*/
class ThemeService {
/**
* Subject for triggering cleanup on service destruction.
*
* @private
*/
#destroyed;
/**
* Creates a new instance
*
* @param {ThemeRegistryService} registry - Service for registering available themes
* @param {ThemeTrackingService} tracker - Service for tracking the currently selected theme
* @param {ThemeStyleManagerService} manager - Service for theme discovery, activation and deactivation
* @param {StorageService} storage - Service for storing the currently selected theme in the browser storage
* @param {ActivatedRoute} activatedRoute - Angular service for managing the current route
* @param {Router} router - Angular service for navigating between routes
* @param {string} storageKey - Key for storing the currently selected theme in browser storage
* @param {string} queryParam - Query parameter name for specifying the theme in the route
*/
constructor(registry, tracker, manager, storage, activatedRoute, router, storageKey, queryParam) {
this.registry = registry;
this.tracker = tracker;
this.manager = manager;
this.storage = storage;
this.activatedRoute = activatedRoute;
this.router = router;
this.storageKey = storageKey;
this.queryParam = queryParam;
/**
* Subject for triggering cleanup on service destruction.
*
* @private
*/
this.#destroyed = new Subject();
/**
* Observable stream that emits the currently active theme.
*/
this.themes$ = this.registry.themes$;
/**
* Observable stream of all registered themes.
*/
this.currentTheme$ = this.tracker.currentTheme$;
// update the current route, the browser storage and the style elements' media attribute,
// when the currently selected theme changes
this.tracker.currentTheme$
.pipe(filter(isNotNull), tap((theme) => this.#updateRouteParam(theme)), tap((theme) => this.#updateStoredTheme(theme)), tap((theme) => manager.use(theme)), takeUntil(this.#destroyed))
.subscribe();
// calculate the initially selected theme
this.registry.themes$
.pipe(debounceTime(1), takeWhile(() => !this.tracker.currentTheme), map((themes) => themes.find((theme) => theme.defaultTheme) || themes[0]), map((defaultTheme) => ({
fromRoute: this.#themeFromRoute,
fromStorage: this.#themeFromStorage,
defaultTheme: defaultTheme?.id,
})), map(({ fromRoute, fromStorage, defaultTheme }) => fromRoute || fromStorage || defaultTheme), tap((initialTheme) => {
if (!this.tracker.currentTheme) {
this.tracker.currentTheme = initialTheme;
}
}), take(1))
.subscribe();
// if we got a storage key, update current theme on storage changes
if (!!this.storageKey) {
this.storage.changes$
.pipe(skipWhile(() => !this.tracker.currentTheme), filter(({ key }) => key === this.storageKey), map(({ newValue }) => newValue), tap((theme) => (this.tracker.currentTheme = theme)), takeUntil(this.#destroyed))
.subscribe();
}
// if we got a query param, update the current theme on route param changes
if (!!this.queryParam) {
this.activatedRoute.queryParamMap
.pipe(skipWhile(() => !this.tracker.currentTheme), filter((map) => map.has(this.queryParam)), map((map) => map.get(this.queryParam)), tap((theme) => (this.tracker.currentTheme = theme)), takeUntil(this.#destroyed))
.subscribe();
}
}
/**
* Cleanup logic to be executed when the service is destroyed.
*
* @internal
*/
ngOnDestroy() {
this.#destroyed.next();
this.#destroyed.complete();
}
/**
* Select the theme to be used
*
* @param {string} theme
*/
selectTheme(theme) {
this.tracker.currentTheme = theme;
}
/**
* Gets the theme from the browser's storage.
*
* @returns {string | null} - The stored theme or null if the storage key is not provided
* @private
*/
get #themeFromStorage() {
if (!this.storageKey) {
return null;
}
return this.storage.getItem(this.storageKey);
}
/**
* Gets the theme from the current route's query.
*
* @returns {string | null} - The current theme or null if the query param is not provided
* @private
*/
get #themeFromRoute() {
if (!this.queryParam) {
return null;
}
return this.activatedRoute.snapshot.queryParamMap.get(this.queryParam);
}
/**
* Updates the browser's url query params with the given theme ID.
*
* Does nothing if the query param is not provided.
*
* @param {string} theme - The theme ID to update in the browser's url query params
* @private
*/
#updateRouteParam(theme) {
if (!this.queryParam) {
return;
}
this.router.navigate([], {
relativeTo: this.activatedRoute,
queryParams: theme ? { [this.queryParam]: theme } : {},
queryParamsHandling: 'merge',
preserveFragment: true,
});
}
/**
* Updates the theme ID in the browser's storage.
*
* Does nothing if the storage key is not provided.
*
* @param {string} theme - The theme ID to save in the browser's storage
* @private
*/
#updateStoredTheme(theme) {
if (!this.storageKey) {
return;
}
this.storage.setItem(this.storageKey, theme);
}
static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeService, deps: [{ token: ThemeRegistryService }, { token: ThemeTrackingService }, { token: ThemeStyleManagerService }, { token: StorageService }, { token: i2.ActivatedRoute }, { token: i2.Router }, { token: STORAGE_KEY, optional: true }, { token: QUERY_PARAM, optional: true }], target: i0.ɵɵFactoryTarget.Injectable }); }
static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeService, providedIn: 'root' }); }
}
i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "17.3.1", ngImport: i0, type: ThemeService, decorators: [{
type: Injectable,
args: [{ providedIn: 'root' }]
}], ctorParameters: () => [{ type: ThemeRegistryService }, { type: ThemeTrackingService }, { type: ThemeStyleManagerService }, { type: StorageService }, { type: i2.ActivatedRoute }, { type: i2.Router }, { type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [STORAGE_KEY]
}] }, { type: undefined, decorators: [{
type: Optional
}, {
type: Inject,
args: [QUERY_PARAM]
}] }] });
/**
* Type guard.
*
* @param {T | null]} arg - The argument to guard
* @internal
*/
function isNotNull(arg) {
return arg !== null;
}
/**
* Generated bundle index. Do not edit.
*/
export { QUERY_PARAM, STORAGE_KEY, StorageService, ThemeRegistryService, ThemeService, ThemeStyleManagerService, ThemeTrackingService, applyThemeIdentifier, extractThemeAnnotations, turnOff, turnOn, unwrap };
//# sourceMappingURL=rb-mwindh-ngx-theme-manager.mjs.map