UNPKG

@rb-mwindh/ngx-theme-manager

Version:

Angular component to switch between different theming stylesheets

682 lines (674 loc) 25 kB
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(/@@id\s+([^\r\n]+)$/m.exec(s)); if (!id) { return null; } const displayName = unwrap(/@@displayName\s+([^\r\n]+)$/m.exec(s)) || id; const description = unwrap(/@@description\s+([^\r\n]+)$/m.exec(s)); const defaultTheme = /@@default/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