Skip to content

Commit

Permalink
feat(core/system-theme-extractor): refactor theme-manager to work asy…
Browse files Browse the repository at this point in the history
…nchronously
  • Loading branch information
Maxim Givanyan committed Jan 19, 2024
1 parent 22b9305 commit 73e3aa7
Show file tree
Hide file tree
Showing 6 changed files with 175 additions and 64 deletions.
10 changes: 8 additions & 2 deletions src/components/super/i-block/mods/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -221,8 +221,14 @@ export default abstract class iBlockMods extends iBlockEvent {
* Initializes the theme modifier and attaches a listener to watch changing of the theme
*/
@hook('created')
protected initThemeModListener(): void {
void this.setMod('theme', this.r.theme?.current);
protected async initThemeModListener(): Promise<void> {
if (this.r.theme == null) {
return;
}

const cur = await this.r.theme.getTheme();

void this.setMod('theme', cur);

this.rootEmitter.on(
'onTheme:change',
Expand Down
7 changes: 6 additions & 1 deletion src/components/super/i-static-page/i-static-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,12 @@ export default abstract class iStaticPage extends iPage {
/**
* A module to manage app themes from the Design System
*/
@system<iStaticPage>(themeManagerFactory)
@system<iStaticPage>((o) => themeManagerFactory(
o,
import('core/kv-storage/engines/cookie').then((cookie) => cookie.syncLocalStorage),
import('core/system-theme-extractor/engines/web').then((engine) => engine.webEngineFactory(o))
))

readonly theme: CanUndef<ThemeManager>;

/**
Expand Down
28 changes: 28 additions & 0 deletions src/components/super/i-static-page/modules/theme/const.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/

/**
* Indicates whether the detection of the user's preferred color scheme is enabled.
* Defaults to `false` if not specified in `DETECT_USER_PREFERENCES`.
*/
export const prefersColorSchemeEnabled =
Object.get<boolean>(DETECT_USER_PREFERENCES, 'prefersColorScheme.enabled') ?? false;

/**
* The name associated with the dark color scheme.
* Defaults to 'dark' if not specified in `DETECT_USER_PREFERENCES`.
*/
export const darkThemeName =
Object.get<string>(DETECT_USER_PREFERENCES, 'prefersColorScheme.aliases.dark') ?? 'dark';

/**
* The name associated with the light color scheme.
* Defaults to 'light' if not specified in `DETECT_USER_PREFERENCES`.
*/
export const lightThemeName =
Object.get<string>(DETECT_USER_PREFERENCES, 'prefersColorScheme.aliases.light') ?? 'light';
13 changes: 11 additions & 2 deletions src/components/super/i-static-page/modules/theme/factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,20 @@

import type iBlock from 'components/super/i-block/i-block';
import ThemeManager from 'components/super/i-static-page/modules/theme/theme-manager';
import type { StorageEngine } from 'core/kv-storage';
import type { SystemThemeExtractor } from 'core/system-theme-extractor';

/**
* Returns an instance of the class for managing interface themes, if that functionality is available
*
* @param component
* @param store
* @param systemTheme
*/
export default function themeManagerFactory(component: iBlock): CanNull<ThemeManager> {
return Object.isString(THEME) ? new ThemeManager(component) : null;
export default function themeManagerFactory(
component: iBlock,
store: CanPromise<StorageEngine>,
systemTheme: CanPromise<SystemThemeExtractor>
): CanNull<ThemeManager> {
return Object.isString(THEME) ? new ThemeManager(component, store, systemTheme) : null;
}
22 changes: 22 additions & 0 deletions src/components/super/i-static-page/modules/theme/interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
/*!
* V4Fire Client Core
* https://github.com/V4Fire/Client
*
* Released under the MIT license
* https://github.com/V4Fire/Client/blob/master/LICENSE
*/

/**
* Represents a visual theme with its associated properties
*/
export interface Theme {
/**
* The value representing the visual theme
*/
value: string;

/**
* Indicates whether the current theme value is derived from system settings
*/
isSystem: boolean;
}
159 changes: 100 additions & 59 deletions src/components/super/i-static-page/modules/theme/theme-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,23 @@
import symbolGenerator from 'core/symbol';

import { factory, SyncStorage, StorageEngine } from 'core/kv-storage';
import * as cookieEngine from 'core/kv-storage/engines/cookie';
import SyncPromise from 'core/promise/sync';

import type iBlock from 'components/super/i-block/i-block';
import type iStaticPage from 'components/super/i-static-page/i-static-page';

import Friend from 'components/friends/friend';
import type { Theme } from 'components/super/i-static-page/modules/theme/interface';
import type { SystemThemeExtractor } from 'core/system-theme-extractor';

import { prefersColorSchemeEnabled, darkThemeName, lightThemeName } from 'components/super/i-static-page/modules/theme/const';

const
$$ = symbolGenerator();

export * from 'components/super/i-static-page/modules/theme/interface';
export * from 'components/super/i-static-page/modules/theme/const';

export default class ThemeManager extends Friend {
override readonly C!: iStaticPage;

Expand All @@ -30,17 +37,27 @@ export default class ThemeManager extends Friend {
/**
* Current theme value
*/
protected currentStore!: string;
protected currentStore!: Theme;

/**
* A promise that resolves when the ThemeManager is initialized.
*/
protected readonly initPromise!: Promise<ThemeManager>;

/**
* An API for obtaining and observing system appearance.
*/
protected systemThemeExtractor!: SystemThemeExtractor;

/**
* Initial theme value
*/
protected readonly initialValue!: string;
protected initialValue!: Theme;

/**
* An API for persistent theme storage
*/
protected readonly themeStorage!: SyncStorage;
protected themeStorage!: SyncStorage;

/**
* An attribute to set the theme value to the root element
Expand All @@ -50,62 +67,105 @@ export default class ThemeManager extends Friend {
/**
* @param component
* @param themeStorageEngine - engine for persistent theme storage
* @param systemThemeExtractor
*/
constructor(component: iBlock, themeStorageEngine: StorageEngine = cookieEngine.syncLocalStorage) {
constructor(
component: iBlock,
themeStorageEngine: CanPromise<StorageEngine>,
systemThemeExtractor: CanPromise<SystemThemeExtractor>
) {
super(component);

if (!Object.isString(THEME)) {
throw new ReferenceError('A theme to initialize is not specified');
}

this.themeStorage = factory(themeStorageEngine);
this.availableThemes = new Set(AVAILABLE_THEMES ?? []);

let theme = THEME;

if (POST_PROCESS_THEME) {
const themeFromCookie = this.themeStorage.get<string>('colorTheme');

if (themeFromCookie != null && this.availableThemes.has(themeFromCookie)) {
theme = themeFromCookie;
}

} else if (Object.isDictionary(DETECT_USER_PREFERENCES)) {
const
prefersColorSchemeEnabled = Object.get<boolean>(DETECT_USER_PREFERENCES, 'prefersColorScheme.enabled') ?? false,
darkTheme = Object.get<string>(DETECT_USER_PREFERENCES, 'prefersColorScheme.aliases.dark') ?? 'dark',
lightTheme = Object.get<string>(DETECT_USER_PREFERENCES, 'prefersColorScheme.aliases.light') ?? 'light';

if (prefersColorSchemeEnabled) {
const darkThemeMq = globalThis.matchMedia('(prefers-color-scheme: dark)');
theme = darkThemeMq.matches ? darkTheme : lightTheme;

this.initThemeListener(darkThemeMq, prefersColorSchemeEnabled, darkTheme, lightTheme);
}
}

this.current = theme;
this.initialValue = theme;

if (!Object.isString(this.themeAttribute)) {
throw new ReferenceError('An attribute name to set themes is not specified');
}

this.availableThemes = new Set(AVAILABLE_THEMES ?? []);

this.initPromise = this.async.promise(
Promise.all([themeStorageEngine, systemThemeExtractor])
.then(async ([storageEngine, systemThemeExtractor]) => {
this.themeStorage = factory(storageEngine);
this.systemThemeExtractor = systemThemeExtractor;

let theme = {value: THEME, isSystem: false};

if (POST_PROCESS_THEME) {
const themeFromStore = this.themeStorage.get<Theme>('colorTheme');

if (themeFromStore != null) {
theme = themeFromStore;
}
} else if (prefersColorSchemeEnabled) {
theme.isSystem = true;
}

this.initialValue = theme;
return this.updateTheme(theme);
})
.then(() => this),
{label: $$.themeManagerInit}
);
}

/**
* Current theme value
* Returns current theme
*/
get current(): string {
async getTheme(): Promise<Theme> {
await this.initPromise;
return this.currentStore;
}

/**
* Sets a new value to the current theme
*
* @param value
* @param [isSystem]
*/
async setTheme(value: string, isSystem?: boolean): Promise<void>;
async setTheme(value: Theme): Promise<void>;
async setTheme(value: Theme | string, isSystem: boolean = false): Promise<void> {
await this.initPromise;

if (typeof value !== 'string') {
isSystem = value.isSystem;
value = value.value;
}

return this.updateTheme({value, isSystem});
}

/**
* Sets a new value to the current theme
*
* @param theme
* @emits `theme:change(value: string, oldValue: CanUndef<string>)`
*/
set current(value: string) {
protected async updateTheme(theme: Theme): Promise<void> {
let
{isSystem, value} = theme;

if (isSystem) {
value = await this.systemThemeExtractor.getSystemTheme();

this.systemThemeExtractor.initThemeChangeListener(
(value: string) => {
if (prefersColorSchemeEnabled) {
value = value === 'dark' ? darkThemeName : lightThemeName;
}

void this.updateTheme({value, isSystem: true});
}
);

} else {
this.systemThemeExtractor.terminateThemeChangeListener();
}

if (!this.availableThemes.has(value)) {
throw new ReferenceError(`A theme with the name "${value}" is not defined`);
}
Expand All @@ -114,33 +174,14 @@ export default class ThemeManager extends Friend {
return;
}

const oldValue = this.currentStore;
const oldValue = this.field.get('currentStore.value');

this.currentStore = value;
this.themeStorage.set('colorTheme', value);
this.currentStore = {value, isSystem};
this.themeStorage.set('colorTheme', this.currentStore);
document.documentElement.setAttribute(this.themeAttribute, value);

void this.component.lfc.execCbAtTheRightTime(() => {
this.component.emit('theme:change', value, oldValue);
});
}

/**
* Initializes an event listener for changes in system appearance
*
* @param mq
* @param enabled
* @param darkTheme
* @param lightTheme
*/
protected initThemeListener(mq: MediaQueryList, enabled: boolean, darkTheme: string, lightTheme: string): void {
if (!enabled) {
return;
}

// TODO: understand why cant we use `this.async.on(mq, 'change', ...)`; https://github.com/V4Fire/Core/issues/369
mq.onchange = this.async.proxy((event: MediaQueryListEvent) => (
this.current = event.matches ? darkTheme : lightTheme
), {single: false, label: $$.themeChange});
}
}

0 comments on commit 73e3aa7

Please sign in to comment.