UNPKG

@rxap/ngx-theme

Version:

This package provides an Angular theme service that allows you to manage and customize the look and feel of your application. It includes features such as dark mode support, theme density control, typography settings, and color palette management. The ser

537 lines (528 loc) 24 kB
import { TinyColor } from '@ctrl/tinycolor'; import { Observable, Subscription, debounceTime, map, tap } from 'rxjs'; import * as i0 from '@angular/core'; import { inject, computed, signal, effect, Injectable, Inject, isDevMode, provideAppInitializer } from '@angular/core'; import * as i1 from '@angular/cdk/layout'; import { ConfigService } from '@rxap/config'; import { PubSubService, RXAP_TOPICS } from '@rxap/ngx-pub-sub'; import { isDefined } from '@rxap/rxjs'; import { DOCUMENT } from '@angular/common'; var ColorPaletteAlgorithm; (function (ColorPaletteAlgorithm) { ColorPaletteAlgorithm["CONSTANTIN"] = "constantin"; ColorPaletteAlgorithm["BUCKNER"] = "buckner"; })(ColorPaletteAlgorithm || (ColorPaletteAlgorithm = {})); function ComputeColorPalette(color, colorPalette = {}, algorithm) { colorPalette = { ...colorPalette }; const baseLight = new TinyColor('#ffffff'); const baseDark = multiply(new TinyColor(color).toRgb(), new TinyColor(color).toRgb()); const baseTriad = new TinyColor(color).tetrad(); switch (algorithm) { case ColorPaletteAlgorithm.CONSTANTIN: colorPalette[50] ??= baseLight.mix(color, 12).toHexString(); colorPalette[100] ??= baseLight.mix(color, 30).toHexString(); colorPalette[200] ??= baseLight.mix(color, 50).toHexString(); colorPalette[300] ??= baseLight.mix(color, 70).toHexString(); colorPalette[400] ??= baseLight.mix(color, 85).toHexString(); colorPalette[500] ??= baseLight.mix(color, 100).toHexString(); colorPalette[600] ??= baseDark.mix(color, 87).toHexString(); colorPalette[700] ??= baseDark.mix(color, 70).toHexString(); colorPalette[800] ??= baseDark.mix(color, 54).toHexString(); colorPalette[900] ??= baseDark.mix(color, 25).toHexString(); colorPalette.A100 ??= baseDark.mix(baseTriad[4], 15).saturate(80).lighten(65).toHexString(); colorPalette.A200 ??= baseDark.mix(baseTriad[4], 15).saturate(80).lighten(55).toHexString(); colorPalette.A400 ??= baseDark.mix(baseTriad[4], 15).saturate(100).lighten(45).toHexString(); colorPalette.A700 ??= baseDark.mix(baseTriad[4], 15).saturate(100).lighten(40).toHexString(); break; case ColorPaletteAlgorithm.BUCKNER: colorPalette[50] ??= baseLight.mix(color, 12).toHexString(); colorPalette[100] ??= baseLight.mix(color, 30).toHexString(); colorPalette[200] ??= baseLight.mix(color, 50).toHexString(); colorPalette[300] ??= baseLight.mix(color, 70).toHexString(); colorPalette[400] ??= baseLight.mix(color, 85).toHexString(); colorPalette[500] ??= baseLight.mix(color, 100).toHexString(); colorPalette[600] ??= baseDark.mix(color, 87).toHexString(); colorPalette[700] ??= baseDark.mix(color, 70).toHexString(); colorPalette[800] ??= baseDark.mix(color, 54).toHexString(); colorPalette[900] ??= baseDark.mix(color, 25).toHexString(); colorPalette.A100 ??= baseDark.mix(baseTriad[3], 15).saturate(80).lighten(48).toHexString(); colorPalette.A200 ??= baseDark.mix(baseTriad[3], 15).saturate(80).lighten(36).toHexString(); colorPalette.A400 ??= baseDark.mix(baseTriad[3], 15).saturate(100).lighten(31).toHexString(); colorPalette.A700 ??= baseDark.mix(baseTriad[3], 15).saturate(100).lighten(28).toHexString(); break; default: colorPalette[50] ??= new TinyColor(color).lighten(52).toHexString(); colorPalette[100] ??= new TinyColor(color).lighten(37).toHexString(); colorPalette[200] ??= new TinyColor(color).lighten(26).toHexString(); colorPalette[300] ??= new TinyColor(color).lighten(12).toHexString(); colorPalette[400] ??= new TinyColor(color).lighten(6).toHexString(); colorPalette[500] ??= new TinyColor(color).toHexString(); colorPalette[600] ??= new TinyColor(color).darken(6).toHexString(); colorPalette[700] ??= new TinyColor(color).darken(12).toHexString(); colorPalette[800] ??= new TinyColor(color).darken(18).toHexString(); colorPalette[900] ??= new TinyColor(color).darken(24).toHexString(); colorPalette.A100 ??= new TinyColor(color).lighten(50).saturate(30).toHexString(); colorPalette.A200 ??= new TinyColor(color).lighten(30).saturate(30).toHexString(); colorPalette.A400 ??= new TinyColor(color).lighten(10).saturate(15).toHexString(); colorPalette.A700 ??= new TinyColor(color).lighten(5).saturate(5).toHexString(); break; } return colorPalette; } function multiply(rgb1, rgb2) { rgb1.b = Math.floor(rgb1.b * rgb2.b / 255); rgb1.g = Math.floor(rgb1.g * rgb2.g / 255); rgb1.r = Math.floor(rgb1.r * rgb2.r / 255); return new TinyColor('rgb ' + rgb1.r + ' ' + rgb1.g + ' ' + rgb1.b); } function ObserveCurrentThemeDensity() { return new Observable((subscriber) => { const observer = new MutationObserver((mutations) => { mutations.forEach((mutation) => { if (mutation.type === 'attributes' && mutation.attributeName === 'class') { const classList = mutation.target.classList; const matchingClasses = Array.from(classList).filter((className) => /density-\d/.test(className)); if (matchingClasses.length > 0) { const match = matchingClasses[0].match(/density-(\d)/); if (match) { subscriber.next(Number(match[1]) * -1); } } else { subscriber.next(0); } } }); }); subscriber.add(() => observer.disconnect()); observer.observe(document.body, { attributes: true, attributeFilter: ['class'], }); }); } const THEME_SETTING_KEY = 'rxap_theme_settings'; /** * Service to manage theme mode (light/dark/system) for the application. * It provides functionality to handle theme preferences, persist them, and synchronize changes. * * This service integrates with media queries to detect system-level theme changes and allows the user * or application logic to toggle or set a specific theme. * * It utilizes dependency injection to access various required services and the document object. * Additionally, it publishes theme-related events for other parts of the application to react to * theme changes. */ class ThemeModeService { constructor(mediaMatcher, document) { this.mediaMatcher = mediaMatcher; this.document = document; this.config = inject(ConfigService); this.pubSub = inject(PubSubService); this.darkMode = computed(() => this.activeTheme() === 'dark'); this.subscriptions = null; this.darkColorSchemaMediaQuery = this.mediaMatcher.matchMedia('(prefers-color-scheme: dark)'); this.themeSetting = signal(this.restoreThemeSetting()); let activeTheme; const themeSetting = this.themeSetting(); switch (themeSetting) { case 'system': activeTheme = this.darkColorSchemaMediaQuery.matches ? 'dark' : 'light'; break; case 'light': activeTheme = 'light'; break; case 'dark': activeTheme = 'dark'; break; default: activeTheme = this.config.get('theme.active', 'light'); break; } this.activeTheme = signal(activeTheme); effect(() => { const setting = this.themeSetting(); this.storeThemeSetting(setting); }); effect(() => { const active = this.activeTheme(); this.setThemeClasses(active); }); this.mediaQueryListener = (event) => { if (this.themeSetting() === 'system') { this.activeTheme.set(event.matches ? 'dark' : 'light'); } }; this.darkColorSchemaMediaQuery.addEventListener('change', this.mediaQueryListener); this.restoreFromPubSub(); } get darkModeLocalStorageKey() { return window?.['__rxap__']?.['ngx']?.['theme']?.['darkMode']?.['key'] ?? THEME_SETTING_KEY; } restoreFromPubSub() { if (this.syncSubscription) { return; } this.syncSubscription = new Subscription(); this.syncSubscription.add(this.pubSub.subscribe(RXAP_TOPICS.theme.darkMode.restore).pipe(debounceTime(1000), map(event => event.data), isDefined(), tap(data => { const active = data ? 'dark' : 'light'; this.themeSetting.set(active); this.activeTheme.set(active); })).subscribe()); } /** * Cleans up subscriptions and event listeners when the service is destroyed. */ ngOnDestroy() { // Remove the media query listener if it exists. this.darkColorSchemaMediaQuery.removeEventListener('change', this.mediaQueryListener); // Unsubscribe from all RxJS subscriptions. this.subscriptions?.unsubscribe(); this.syncSubscription?.unsubscribe(); } restoreThemeSetting() { const setting = localStorage.getItem(this.darkModeLocalStorageKey) ?? this.config.get('theme.setting', 'system'); if (setting && ['system', 'dark', 'light'].includes(setting)) { return setting; } return 'system'; } storeThemeSetting(setting) { localStorage.setItem(this.darkModeLocalStorageKey, setting); } removeThemeClasses() { this.document.body.classList.remove('light'); this.document.body.classList.remove('dark'); } setThemeClasses(active) { this.document.body.classList.remove(active === 'light' ? 'dark' : 'light'); this.document.body.classList.add(active); } /** * Toggles the application theme between 'light' and 'dark' modes. * It checks the currently active theme and switches to the opposite mode. * * @return {void} Does not return a value. */ toggleTheme() { const currentActive = this.activeTheme(); // Determine the new setting based on the *currently active* theme. const newActive = currentActive === 'light' ? 'dark' : 'light'; this.themeSetting.set(newActive); this.activeTheme.set(newActive); } /** * Sets the active theme for the application and optionally publishes the change. * * @param {ActiveTheme} active - The theme to be set as active. * @param {boolean} [publish=true] - Indicates whether to publish the theme change event. * @return {void} */ setTheme(active, publish = true) { this.themeSetting.set(active); this.activeTheme.set(active); if (publish) { this.pubSub.publish(RXAP_TOPICS.theme.darkMode.changed, this.darkMode()); } } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ThemeModeService, deps: [{ token: i1.MediaMatcher }, { token: DOCUMENT }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ThemeModeService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ThemeModeService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i1.MediaMatcher }, { type: Document, decorators: [{ type: Inject, args: [DOCUMENT] }] }] }); class ThemeService { constructor(mediaMatcher) { this.mediaMatcher = mediaMatcher; this.config = inject(ConfigService); this.pubSub = inject(PubSubService); this.themeModeService = inject(ThemeModeService); this.darkMode = this.themeModeService.darkMode; this.themeName = signal(this.getTheme()); this.density = signal(this.getDensity()); this.typography = signal(this.getTypography()); } restore() { if (isDevMode()) { console.log('Restore theme settings from local storage'); } this.restoreThemeName(); this.restoreDensity(); this.restoreTypography(); this.restoreFromPubSub(); } restoreFromPubSub() { if (this.syncSubscription) { return; } this.syncSubscription = new Subscription(); this.syncSubscription.add(this.pubSub.subscribe(RXAP_TOPICS.theme.density.restore).pipe(debounceTime(1000), map(event => event.data), isDefined(), tap(data => this.setDensity(data, false, false))).subscribe()); this.syncSubscription.add(this.pubSub.subscribe(RXAP_TOPICS.theme.preset.restore).pipe(debounceTime(1000), map(event => event.data), isDefined(), tap(data => this.setTheme(data, false, false))).subscribe()); this.syncSubscription.add(this.pubSub.subscribe(RXAP_TOPICS.theme.typography.restore).pipe(debounceTime(1000), map(event => event.data), isDefined(), tap(data => this.setTypography(data, false, false))).subscribe()); } get themeNameLocalStorageKey() { return window?.['__rxap__']?.['ngx']?.['theme']?.['name']?.['key'] ?? `rxap-theme-name`; } get densityLocalStorageKey() { return window?.['__rxap__']?.['ngx']?.['theme']?.['density']?.['key'] ?? `rxap-theme-density`; } get typographyLocalStorageKey() { return window?.['__rxap__']?.['ngx']?.['theme']?.['typography']?.['key'] ?? `rxap-theme-typography`; } // region restore restoreThemeName() { const themeName = localStorage.getItem(this.themeNameLocalStorageKey); if (themeName) { this.setTheme(themeName, true); } return themeName; } restoreTypography() { const typography = localStorage.getItem(this.typographyLocalStorageKey); if (typography) { this.setTypography(typography, true); } return typography; } restoreDensity() { const density = localStorage.getItem('rxap-theme-density'); if (density) { const value = Number(density); if (value <= 0 && value >= -3) { this.setDensity(Number(density), true); return value; } } return null; } // endregion toggleDarkTheme() { this.themeModeService.toggleTheme(); } // region set theme configuration state setDarkTheme(darkMode, silent = false, publish = true) { this.themeModeService.setTheme(darkMode ? 'dark' : 'light', publish); } setDensity(density, silent = false, publish = true) { this.applyDensity(density); if (this.density() !== density) { this.density.set(density); if (!silent) { localStorage.setItem(this.densityLocalStorageKey, String(density)); if (publish) { this.pubSub.publish(RXAP_TOPICS.theme.density.changed, density); } } } } setTypography(typography, silent = false, publish = true) { this.applyTypography(typography); if (this.typography() !== typography) { this.typography.set(typography); if (!silent) { localStorage.setItem(this.typographyLocalStorageKey, typography); if (publish) { this.pubSub.publish(RXAP_TOPICS.theme.typography.changed, typography); } } } } setTheme(themeName, silent = false, publish = true) { this.applyTheme(themeName); this.density.set(this.getDensity()); this.typography.set(this.getTypography()); if (this.themeName() !== themeName) { this.themeName.set(themeName); if (!silent) { localStorage.setItem(this.themeNameLocalStorageKey, themeName); if (publish) { this.pubSub.publish(RXAP_TOPICS.theme.preset.changed, themeName); } } } } // endregion // region apply theme configuration state applyDensity(density) { document.body.classList.remove('density-0', 'density-1', 'density-2', 'density-3'); if (density < 0) { document.body.classList.add(`density${density}`); } } applyTypography(typography) { document.body.style.setProperty('--font-family', `var(--font-family-${typography})`); } applyTheme(themeName) { if (themeName === 'default') { this.resetToDefaultTheme(); return; } const theme = this.getThemeConfig(themeName); if (theme.primaryColor?.color) { this.setCssColorVariables('primary', theme.primaryColor.color); } if (theme.accentColor?.color) { this.setCssColorVariables('accent', theme.accentColor.color); } if (theme.warnColor?.color) { this.setCssColorVariables('warn', theme.warnColor.color); } if (theme.density !== undefined) { this.applyDensity(theme.density); } if (theme.typography) { this.applyTypography(theme.typography); } document.body.style.setProperty(`--theme-name`, themeName); } // endregion // region get theme configuration state getDensity() { let density = 0; document.body.classList.forEach((className) => { const match = className.match(/density-([123])/); if (match) { density = Number(match[1]) * -1; } }); return density; } getTypography() { const variable = document.body.style.getPropertyValue('--font-family'); const match = variable.match(/var\(--font-family-(.*)\)/); if (match) { return match[1]; } return 'default'; } getTheme() { return document.body.style.getPropertyValue('--theme-name') || 'default'; } // endregion // region get available getAvailableColorPalettes() { const colorPalettesConfigs = this.config.get('colorPalettes', false); if (!colorPalettesConfigs) { return null; } const availableColorPalettes = Object.keys(colorPalettesConfigs); availableColorPalettes.unshift('default'); return availableColorPalettes; } getAvailableThemes() { const themeConfigs = this.config.get('themes', false); if (!themeConfigs) { return null; } const availableThemes = Object.keys(themeConfigs); availableThemes.unshift('default'); return availableThemes; } getAvailableTypographies() { const availableTypographies = this.config.get('typographies', false); if (!availableTypographies) { return null; } return Array .from(document.styleSheets) .filter(sheet => sheet.href === null || sheet.href.startsWith(window.location.origin)) .flatMap(sheet => Array.from(sheet.cssRules || [])) .filter((rule) => rule.selectorText === ':root') .flatMap((rule) => Array.from(rule.style)) .filter((prop) => prop.startsWith('--')) .filter((prop) => prop.startsWith('--font-family-')) .map((prop) => prop.replace('--font-family-', '')) .sort(); } // endregion getColorPalette(colorPaletteName) { const colorPaletteConfig = this.config.getOrThrow(`colorPalettes.${colorPaletteName}`); return this.coerceColorPalette(colorPaletteConfig); } // region utility coerceColorPalette(colorPaletteConfig) { let colorPalette = {}; if (colorPaletteConfig.color) { if (Object.keys(colorPaletteConfig.color).length !== 14) { // the color palette is not complete if (colorPaletteConfig.base) { colorPalette = ComputeColorPalette(colorPaletteConfig.base, colorPaletteConfig.color, colorPaletteConfig.algorithm); } } colorPalette = colorPaletteConfig.color; } else if (colorPaletteConfig.base) { colorPalette = ComputeColorPalette(colorPaletteConfig.base, {}, colorPaletteConfig.algorithm); } if (Object.keys(colorPalette).length === 0) { throw new Error('FATAL: The color palette has neither a base nor a color property'); } return colorPalette; } getThemeConfig(themeName) { const themeConfig = this.config.getOrThrow(`themes.${themeName}`); if (themeConfig.accentColor) { themeConfig.accentColor.color = this.coerceColorPalette(themeConfig.accentColor); } if (themeConfig.primaryColor) { themeConfig.primaryColor.color = this.coerceColorPalette(themeConfig.primaryColor); } if (themeConfig.warnColor) { themeConfig.warnColor.color = this.coerceColorPalette(themeConfig.warnColor); } return themeConfig; } setCssColorVariables(name, colorPalette) { this.clearCssColorVariables(name); for (const [index, color] of Object.entries(colorPalette)) { document.body.style.setProperty(`--${name}-${index}`, color); } } clearCssColorVariables(name) { document.body.style.removeProperty(`--${name}-50`); for (let index = 100; index <= 900; index += 100) { document.body.style.removeProperty(`--${name}-${index}`); } document.body.style.removeProperty(`--${name}-a100`); document.body.style.removeProperty(`--${name}-a200`); document.body.style.removeProperty(`--${name}-a400`); document.body.style.removeProperty(`--${name}-a700`); } resetToDefaultTheme() { this.clearCssColorVariables('primary'); this.clearCssColorVariables('accent'); this.clearCssColorVariables('warn'); this.setDensity(0); this.setTypography('default'); document.body.style.removeProperty(`--theme-name`); localStorage.removeItem(this.themeNameLocalStorageKey); localStorage.removeItem(this.densityLocalStorageKey); localStorage.removeItem(this.typographyLocalStorageKey); } static { this.ɵfac = i0.ɵɵngDeclareFactory({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ThemeService, deps: [{ token: i1.MediaMatcher }], target: i0.ɵɵFactoryTarget.Injectable }); } static { this.ɵprov = i0.ɵɵngDeclareInjectable({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ThemeService, providedIn: 'root' }); } } i0.ɵɵngDeclareClassMetadata({ minVersion: "12.0.0", version: "19.2.16", ngImport: i0, type: ThemeService, decorators: [{ type: Injectable, args: [{ providedIn: 'root' }] }], ctorParameters: () => [{ type: i1.MediaMatcher }] }); function provideTheme() { return [ provideAppInitializer(() => { const initializerFn = ((themeService) => () => themeService.restore())(inject(ThemeService)); return initializerFn(); }) ]; } // region // endregion /** * Generated bundle index. Do not edit. */ export { ColorPaletteAlgorithm, ComputeColorPalette, ObserveCurrentThemeDensity, THEME_SETTING_KEY, ThemeModeService, ThemeService, provideTheme }; //# sourceMappingURL=rxap-ngx-theme.mjs.map