diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ef4932f00..5072839288 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,19 @@ Changelog _Note: Gaps between patch versions are faulty, broken or test releases._ -## v4.0.0-beta.?? (2024-01-??) +## v4.0.0-beta.?? (2023-??-??) + +#### :boom: Breaking Change + +* Refactored api: replaced the getter/setter named `current` with get/set methods `components/super/i-static-page/modules/theme/theme-manager` + +#### :rocket: New Feature + +* `components/super/i-static-page/modules/theme/theme-manager` + * Added possibility to get/set theme from/to cookie + * Added possibility to specify system theme extractor for theme-manager + * Added possibility to use systemTheme by calling `useSystem` method +* Released module `core/system-theme-extractor` #### :bug: Bug Fix diff --git a/build/globals.webpack.js b/build/globals.webpack.js index c1ced89c08..24d211eff4 100644 --- a/build/globals.webpack.js +++ b/build/globals.webpack.js @@ -81,6 +81,8 @@ module.exports = { DETECT_USER_PREFERENCES: s(config.theme.detectUserPreferences()), + POST_PROCESS_THEME: s(config.theme.postProcessor), + DS: runtime.passDesignSystem && pzlr.designSystem ? s(getDS()) : null, diff --git a/config/default.js b/config/default.js index 171f66747a..a0494e5a56 100644 --- a/config/default.js +++ b/config/default.js @@ -1093,9 +1093,9 @@ module.exports = config.createConfig({dirs: [__dirname, 'client']}, { * ```js * { * prefersColorScheme: { + * // This flag indicates whether dark or light themes will be detected based on the user's settings. * enabled: true, * - * // This flag indicates whether dark or light themes will be detected based on the user's settings. * // If you want to provide custom aliases for theme names, you can pass them as a dictionary. * // If your design system does not provide themes from this dictionary, the build will fail. * // If you do not specify an aliases dictionary, the default values `dark` and `light` will be used. diff --git a/docs/ds/README.md b/docs/ds/README.md new file mode 100644 index 0000000000..aec07a8a89 --- /dev/null +++ b/docs/ds/README.md @@ -0,0 +1,532 @@ +- [Design System](#design-system) + * [Design System Package](#design-system-package) + + [Meta](#meta) + + [colors](#colors) + + [text](#text) + + [shadows](#shadows) + + [components](#components) + + [Miscellaneous](#miscellaneous) + + [Icons and Illustrations](#icons-and-illustrations) + * [Installation and Configuration](#installation-and-configuration) + + [runtime](#runtime) + - [ds/use-css-vars](#dsuse-css-vars) + - [passDesignSystem](#passdesignsystem) + + [theme](#theme) + - [default](#default) + - [include](#include) + - [postProcessor](#postprocessor) + - [postProcessorTemplate](#postprocessortemplate) + - [attribute](#attribute) + - [detectUserPreferences](#detectuserpreferences) + + [Global Variables Provided by Webpack](#global-variables-provided-by-webpack) + - [DS](#ds) + - [DS_COMPONENTS_MODS](#dscomponentsmods) + - [THEME](#theme-1) + - [THEME_ATTRIBUTE](#themeattribute) + - [AVAILABLE_THEMES](#availablethemes) + - [DETECT_USER_PREFERENCES](#detectuserpreferences-1) + - [POST_PROCESS_THEME](#postprocesstheme) + * [Working with the Design System in the Project](#working-with-the-design-system-in-the-project) + + [Colors](#colors-1) + + [Icons](#icons) + + [Text](#text-1) + + [dsShadow](#dsshadow) + + [dsGradient](#dsgradient) + + [r](#r) + + [generateRules](#generaterules) + * [Plugins](#plugins) + + [injector(componentName: string)](#injectorcomponentname-string) + + [getDSVariables](#getdsvariables) + + [getDSValue(obj: Dictionary, path: string)](#getdsvalueobj-dictionary-path-string) + + [getDSTextStyles(styleName: string)](#getdstextstylesstylename-string) + + [getDSColor(colorName: string, idx: number)](#getdscolorcolorname-string-idx-number) + + [defaultTheme](#defaulttheme) + + [availableThemes](#availablethemes) + + [themeAttribute](#themeattribute) + + [darkThemeName](#darkthemename) + + [lightThemeName](#lightthemename) + +# Design System + +---- +## Design System Package + +Utilizing a design system offers the chance to maintain a consistent user interface throughout the application and connect diverse themes. +Now, let's delve into the structure and features of a design system package. + +### Meta + +This includes details about deprecated tokens and their replacements, themes that the design system supports, +or any other relevant supporting information. + +```js +// ds.js +module.exports = { + meta: { + themes: ['dark', 'light'], + deprecated: { + 'blue': {renamedTo: 'darkBlue'}, + 'yellow': true + } + } +} +``` + +### colors + +Within the color section, you are able to establish the color palette for your project. + +Here’s an example: + +```js +// ds.js +module.exports = { + colors: { + blue: '#0000FF', + // nested paths are also supported + static: { + black: '#000000', + white: '#FFFFFF', + }, + // and arrays + black: ['#000000', '#000001', '#000002'] + } +} +``` + +You can also set theme colors: + +```js +// ds.js +module.exports = { + colors: { + theme: { + dark: { + blue: '#0000ff' + }, + light: { + blue: '#1b7ced' + } + } + } +} +``` + +### text + +Text styles used in the project are defined in the following manner: + +```js +// ds.js +module.exports = { + text: { + 'Headline-Main': { + fontFamily: 'Roboto', + fontWeight: 800, + fontSize: '48px', + lineHeight: '56px' + }, + // ... + } +} +``` + +### shadows + +Shadows: + +```js +// ds.js +module.exports = { + shadows: { + Scroll: '0px 1px 2px rgba(25, 25, 26, 0.06)', + // ... + } +} +``` + +### components + +Styles for basic components: buttons, checkboxes, switches, etc. + +```js +// ds.js +module.exports = { + components: { + bComponent: { + mods: { + size: { + m: { /* styles */ }, + l: { /* styles */ } + }, + exterior: { + switch: { /* styles */ }, + button: { /* styles */ } + } + }, + block: { + checkbox: { /* styles */ }, + label: { /* styles */ }, + } + }, + // ... + } +} +``` + +### Miscellaneous + +The styles for elements such as gradients, rounding, spacing, etc., can be established in a similar manner as with [shadows](#shadows). + +### Icons and Illustrations + +Icons within the design system package are stored in the `icons` folder. +If there's a division into groups within the designers' blueprints, the package will incorporate subfolders. +For instance: if the design system tools contain several icon sizes with the same names but different shapes, +these can be differentiated by dividing them into groups like `24/icon_name`, `16/icon_name`, etc. +Thus, in the design system package, developers obtain folders named `24`, `16`, +each of which contain corresponding sets of icons in `SVG` format. + +Various illustrations can also be included in the design system. These should be placed in the `illustrations` folder +and can later be integrated with the template or styles. + +```snakeskin +- template index + < img & + :src = require('ds/illustrations/image.svg') + . + +``` +```stylus +&__img + background-image url("ds/illustrations/image.svg") +``` + +--- +## Installation and Configuration + +To install the design system, follow the steps below: + +- Install the package as a dependency +- Specify the package name in the `designSystem` field of the `.pzlrrc` file + +Once you've done these, you will be able to build the project. + +### runtime + +#### ds/use-css-vars + +If `true`, the values of the design system will be written in CSS variables. + +#### passDesignSystem + +If `true`, the design system object will be available as a global variable `DS`. + +### theme + +To utilize themes, ensure that the available themes are defined in the [meta](#meta) section of the design system. +Additionally, a collection of fields that will vary their appearance depending on the chosen theme should be present. + +```js +// ds.js +module.exports = { + meta: { + themes: ['dark', 'light'], + themedFields: ['colors', 'shadows'] + } +} +``` + +After setting up the themes in your design system, your project needs to be configured to support theme usage. + +#### default + +The default theme for the application. + +#### include + +Themes that need to be included in the build. If `true`, then all themes. + +#### postProcessor + +If set to true, the theme attribute will be processed by a proxy server, such as Nginx. +The proxy server will interpolate the theme value from a cookie or header to the theme attribute. +Otherwise, the theme attributes will be sourced from the JS runtime. + +#### postProcessorTemplate + +If `true`, the theme attribute will be processed by a proxy server (e.g., Nginx). +Otherwise, the attribute will be set in the JS runtime. + +#### attribute + +The name of the data attribute in which the current theme's value will be inserted. + +#### detectUserPreferences + +A dictionary of user preferences that will automatically be determined depending +on the user's system settings. To use this parameter, it is necessary +that the [postProcessor](#postprocessor) parameter is set to `false`. + +### Global Variables Provided by Webpack + +#### DS + +The object with the design system. Passed when the [passDesignSystem](#passdesignsystem) flag is enabled. + +#### DS_COMPONENTS_MODS + +Component modifiers + +#### THEME + +Default theme. See [default](#default) + +#### THEME_ATTRIBUTE + +See [attribute](#attribute) + +#### AVAILABLE_THEMES + +Available themes. Uses the [include](#include) or [default](#default) configuration values, if `include` is not defined. + +#### DETECT_USER_PREFERENCES + +See [detectUserPreferences](#detectuserpreferences) + +#### POST_PROCESS_THEME + +See [postProcessor](#postprocessor) + +--- +## Working with the Design System in the Project + +### Colors + +Colors can be obtained by identifier. Complex paths and arrays with shades are also supported. + +```stylus +c("blue") // normal identifier +с("decor/primary/blue") // nesting, when colors are divided into subgroups +c("blue", 3) // if the value is an array + +``` + +### Icons + +There are two ways to connect icons. Monochrome icons are connected using a global helper. + +```stylus +i("24/foo") +``` + +If you need to connect a colored icon, you can use a directive. + +```snakeskin +- template index + < . v-icon = '24/foo' + +``` + +Colored icons can have local CSS variables so that they can be colored from the outside. + +```snakeskin +- template index + < .&__icon v-icon = '24/foo' + +``` +```stylus +&__icon + --main-color c("blue") + --secondary-color c("green") +``` + +### Text + +The global mixin `t` is used to add a font. + +```stylus +t("Caption L/Medium") +``` + +### dsShadow + +Mixin for getting a shadow. + +```stylus +dsShadow("Button") +``` + +### dsGradient + +Mixin for getting a gradient. + +```stylus +dsGradient("Button") +``` + +### r + +Mixin for getting rounding. + +```stylus +r("s") +``` + +### generateRules + +When building the project, the settings for each component are written to its `$p` object from the `components` field +of the design system package. +You need to describe the function for creating styles and pass it as the second parameter +to `generateRules(obj: Dictionary; fn: Function)`, and the first parameter is the settings object `$p`. +If you want to write all the rules using variables, return flag +`'ds/use-css-vars': true` from the `runtime` function in `config/default.js` (see [ds/use-css-vars](#dsuse-css-vars)). + +You can get properties by key using the [getDSValue](#getdsvalueobj-dictionary-path-string) function. + +Typically, adapters are written in such a way that the output object/field content +with settings correspond to the `CSSStyleDeclaration` type, and the field names to the element names in the code. + +Among the built-in functions, there is also `interpolate-props(obj: CSSStyleDeclaration)`, +which will expand your object into a set of CSS rules. + +See example [here](https://github.com/V4Fire/Client/blob/0ce859731c7dbc44629ee0b41e849c9f5fa1ca11/src/components/form/b-button/b-button-ds.styl) + +--- +## Plugins + +Custom Stylus plugins are available for working with the design system. +Some plugins are utilized in previously mentioned mixins, while others can operate independently. + +### injector(componentName: string) + +This plugin injects additional options into component mixin options ($p). + +```stylus +$p = { + bButton: injector("bButton") +} + +// If `useCSSVarsInRuntime` is enabled +// +// { +// values: { +// mods: { +// size: { +// s: { +// offset: { +// top: 'var(--bButton-mods-size-s-offset-top)' +// } +// } +// } +// } +// } +// } + +// Otherwise +// +// { +// values: { +// mods: { +// size: { +// s: { +// offset: { +// top: 5px +// } +// } +// } +// } +// } +// } +``` +### getDSVariables + +This function retrieves the design system CSS variables along with their values. + +```stylus +getDSVariables() + +// { +// '--colors-primary': #0F9 +// } + +// To convert an object to properties, use interpolate-props. + +interpolate-props(getDSVariables("light"), false) +// --colors-primary #0F9 +``` + +### getDSValue(obj: Dictionary, path: string) + +This function returns a value from the design system based on the specified group and path. +Providing only the first argument returns parameters for the entire group, rather than a single value. +No arguments will return the entire design system object. + +```stylus +getDSValue(colors "green.0") // rgba(0, 255, 0, 1) +``` + +### getDSTextStyles(styleName: string) + +Returns an object containing text styles for the given style name. + +```stylus +getDSTextStyles(Small) + +// Notice, all values are Stylus types +// +// { +// fontFamily: 'Roboto', +// fontWeight: 400, +// fontSize: '14px', +// lineHeight: '16px' +// } +``` + +### getDSColor(colorName: string, idx: number) + +Retrieves color(s) (or CSS var(s) if themes are included) from the design system by the specified name and identifier (optional). + +```stylus +getDSColor("blue", 1) // rgba(0, 0, 255, 1) +``` + +### defaultTheme + +Returns the default theme value. See [default](#default) + +### availableThemes + +Provides a list of available themes. See the [include](#include) section. + +### themeAttribute + +Returns the attribute name to set the theme value to the root element. See [attribute](#attribute) + +```stylus +// Associating CSS variables with corresponding theme +for name in availableThemes() + html[{themeAttribute()}={"%s" % name}] + interpolate-props(getDSVariables(name), false) +``` + +### darkThemeName + +Returns the theme name that will be associated with the dark theme. +By default, it is `dark`. See [detectUserPreferences](#detectuserpreferences) + +### lightThemeName + +Returns the theme name that will be associated with the light theme. +By default, it is `light`. See [detectUserPreferences](#detectuserpreferences) + +```stylus +// Styling components for specific themes +b-my-component + &_theme_{lightThemeName()} + // ... + &_theme_{darkThemeName()} + // ... +``` + +--- diff --git a/index.d.ts b/index.d.ts index 029f274115..3b85ddb645 100644 --- a/index.d.ts +++ b/index.d.ts @@ -47,6 +47,13 @@ declare const DETECT_USER_PREFERENCES: CanUndef< }>> >; +/** + * If set to true, the theme attribute will be processed by a proxy server, such as Nginx. + * The proxy server will interpolate the theme value from a cookie or header to the theme attribute. + * Otherwise, the theme attributes will be sourced from the JS runtime. + */ +declare const POST_PROCESS_THEME: CanUndef; + declare const DS: CanUndef; declare const DS_COMPONENTS_MODS: CanUndef<{ [name: string]: Nullable>; diff --git a/src/components/super/i-block/i-block.ts b/src/components/super/i-block/i-block.ts index e251f6fe93..b291c8cf1f 100644 --- a/src/components/super/i-block/i-block.ts +++ b/src/components/super/i-block/i-block.ts @@ -31,6 +31,7 @@ export { Module } from 'components/friends/module-loader'; export * from 'components/super/i-block/const'; export * from 'components/super/i-block/interface'; +export { Theme } from 'components/super/i-block/mods'; export { ComponentEvent, InferEvents, InferComponentEvents } from 'components/super/i-block/event'; export { prop, field, system, computed, hook, watch, wait } from 'components/super/i-block/decorators'; diff --git a/src/components/super/i-block/mods/index.ts b/src/components/super/i-block/mods/index.ts index 9676b93f96..0f5b4710fe 100644 --- a/src/components/super/i-block/mods/index.ts +++ b/src/components/super/i-block/mods/index.ts @@ -19,10 +19,13 @@ import { initMods, mergeMods, getReactiveMods, ModsDict, ModsDecl } from 'compon import type iBlock from 'components/super/i-block/i-block'; import iBlockEvent from 'components/super/i-block/event'; +import type { Theme } from 'components/super/i-block/mods/interface'; const $$ = symbolGenerator(); +export * from 'components/super/i-block/mods/interface'; + @component() export default abstract class iBlockMods extends iBlockEvent { @system({merge: mergeMods, init: initMods}) @@ -222,11 +225,17 @@ export default abstract class iBlockMods extends iBlockEvent { */ @hook('created') protected initThemeModListener(): void { - void this.setMod('theme', this.r.theme?.current); + if (this.r.theme == null) { + return; + } + + const cur = this.r.theme.get(); + + void this.setMod('theme', cur.value); this.rootEmitter.on( 'onTheme:change', - (v: string) => this.setMod('theme', v), + (v: Theme) => this.setMod('theme', v.value), {label: $$.themeChanged} ); } diff --git a/src/components/super/i-block/mods/interface.ts b/src/components/super/i-block/mods/interface.ts new file mode 100644 index 0000000000..65da58ddc2 --- /dev/null +++ b/src/components/super/i-block/mods/interface.ts @@ -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; +} diff --git a/src/components/super/i-static-page/i-static-page.ts b/src/components/super/i-static-page/i-static-page.ts index 66ae9fc239..12178cddf9 100644 --- a/src/components/super/i-static-page/i-static-page.ts +++ b/src/components/super/i-static-page/i-static-page.ts @@ -18,6 +18,8 @@ import { RestrictedCache } from 'core/cache'; import { setLocale, locale } from 'core/i18n'; import type { AppliedRoute, InitialRoute } from 'core/router'; +import * as cookie from 'core/kv-storage/engines/cookie'; +import { webEngineFactory } from 'core/system-theme-extractor/engines/web'; import { @@ -96,7 +98,12 @@ export default abstract class iStaticPage extends iPage { /** * A module to manage app themes from the Design System */ - @system(themeManagerFactory) + @system((o) => themeManagerFactory( + o, + cookie.syncLocalStorage, + webEngineFactory(o) + )) + readonly theme: CanUndef; /** diff --git a/src/components/super/i-static-page/modules/theme/CHANGELOG.md b/src/components/super/i-static-page/modules/theme/CHANGELOG.md index 9408fe6067..20083c86c3 100644 --- a/src/components/super/i-static-page/modules/theme/CHANGELOG.md +++ b/src/components/super/i-static-page/modules/theme/CHANGELOG.md @@ -9,6 +9,17 @@ Changelog > - :house: [Internal] > - :nail_care: [Polish] +## v4.0.0-beta.?? (2023-??-??) + +#### :boom: Breaking Change + +* Refactored api: replaced the getter/setter named `current` with get/set methods + +#### :rocket: New Feature + +* Added possibility to get/set theme from/to cookie +* Added possibility to use systemTheme by calling `useSystem` method + ## v4.0.0-beta.20 (2023-09-13) #### :rocket: New Feature diff --git a/src/components/super/i-static-page/modules/theme/const.ts b/src/components/super/i-static-page/modules/theme/const.ts new file mode 100644 index 0000000000..91e393a0ba --- /dev/null +++ b/src/components/super/i-static-page/modules/theme/const.ts @@ -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(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(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(DETECT_USER_PREFERENCES, 'prefersColorScheme.aliases.light') ?? 'light'; diff --git a/src/components/super/i-static-page/modules/theme/factory.ts b/src/components/super/i-static-page/modules/theme/factory.ts index b47a58cb07..7c70ce61e5 100644 --- a/src/components/super/i-static-page/modules/theme/factory.ts +++ b/src/components/super/i-static-page/modules/theme/factory.ts @@ -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 { - return Object.isString(THEME) ? new ThemeManager(component) : null; +export default function themeManagerFactory( + component: iBlock, + store: StorageEngine, + systemTheme: SystemThemeExtractor +): CanNull { + return Object.isString(THEME) ? new ThemeManager(component, store, systemTheme) : null; } diff --git a/src/components/super/i-static-page/modules/theme/index.ts b/src/components/super/i-static-page/modules/theme/index.ts index ea29bb87c2..716988bced 100644 --- a/src/components/super/i-static-page/modules/theme/index.ts +++ b/src/components/super/i-static-page/modules/theme/index.ts @@ -13,3 +13,4 @@ export { default } from 'components/super/i-static-page/modules/theme/factory'; export { default as ThemeManager } from 'components/super/i-static-page/modules/theme/theme-manager'; +export * from 'components/super/i-static-page/modules/theme/const'; diff --git a/src/components/super/i-static-page/modules/theme/theme-manager.ts b/src/components/super/i-static-page/modules/theme/theme-manager.ts index 0ecd9036d7..35a555ea11 100644 --- a/src/components/super/i-static-page/modules/theme/theme-manager.ts +++ b/src/components/super/i-static-page/modules/theme/theme-manager.ts @@ -6,15 +6,18 @@ * https://github.com/V4Fire/Client/blob/master/LICENSE */ -import symbolGenerator from 'core/symbol'; +import { factory, SyncStorage, StorageEngine } from 'core/kv-storage'; import type iBlock from 'components/super/i-block/i-block'; +import type { Theme } 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 { SystemThemeExtractor } from 'core/system-theme-extractor'; -const - $$ = symbolGenerator(); +import { prefersColorSchemeEnabled, darkThemeName, lightThemeName } from 'components/super/i-static-page/modules/theme/const'; + +export * from 'components/super/i-static-page/modules/theme/const'; export default class ThemeManager extends Friend { override readonly C!: iStaticPage; @@ -27,99 +30,162 @@ export default class ThemeManager extends Friend { /** * Current theme value */ - protected currentStore!: string; + protected current!: Theme; + + /** + * An API for obtaining and observing system appearance. + */ + protected systemThemeExtractor!: SystemThemeExtractor; /** - * Initial theme value + * An API for persistent theme storage */ - protected readonly initialValue!: string; + protected themeStorage!: SyncStorage; /** * An attribute to set the theme value to the root element */ protected readonly themeAttribute: CanUndef = THEME_ATTRIBUTE; - constructor(component: iBlock) { + /** + * @param component + * @param themeStorageEngine - engine for persistent theme storage + * @param systemThemeExtractor + */ + constructor( + component: iBlock, + themeStorageEngine: StorageEngine, + systemThemeExtractor: SystemThemeExtractor + ) { super(component); - if (!Object.isString(THEME)) { - throw new ReferenceError('A theme to initialize is not specified'); + if (!Object.isString(this.themeAttribute)) { + throw new ReferenceError('An attribute name to set themes is not specified'); + } + + if (POST_PROCESS_THEME && prefersColorSchemeEnabled) { + throw new Error('"postProcessor" param cant be enabled with "detectUserPreferences"'); } this.availableThemes = new Set(AVAILABLE_THEMES ?? []); - let theme = THEME; + this.themeStorage = factory(themeStorageEngine); + this.systemThemeExtractor = systemThemeExtractor; - if (Object.isDictionary(DETECT_USER_PREFERENCES)) { - const - prefersColorSchemeEnabled = Object.get(DETECT_USER_PREFERENCES, 'prefersColorScheme.enabled') ?? false, - darkTheme = Object.get(DETECT_USER_PREFERENCES, 'prefersColorScheme.aliases.dark') ?? 'dark', - lightTheme = Object.get(DETECT_USER_PREFERENCES, 'prefersColorScheme.aliases.light') ?? 'light'; + let + theme: Theme = {value: this.defaultTheme, isSystem: false}; - if (prefersColorSchemeEnabled) { - const darkThemeMq = globalThis.matchMedia('(prefers-color-scheme: dark)'); - theme = darkThemeMq.matches ? darkTheme : lightTheme; + if (POST_PROCESS_THEME) { + const themeFromStore = this.themeStorage.get('colorTheme'); - this.initThemeListener(darkThemeMq, prefersColorSchemeEnabled, darkTheme, lightTheme); + if (themeFromStore != null) { + theme = themeFromStore; } } - this.current = theme; - this.initialValue = theme; + if (theme.isSystem || prefersColorSchemeEnabled) { + void this.useSystem(); - if (!Object.isString(this.themeAttribute)) { - throw new ReferenceError('An attribute name to set themes is not specified'); + } else { + this.changeTheme(theme); } } /** - * Current theme value + * Default theme from config + */ + protected get defaultTheme(): string { + if (!Object.isString(THEME)) { + throw new ReferenceError('A theme to initialize is not specified'); + } + + return THEME; + } + + /** + * Returns current theme */ - get current(): string { - return this.currentStore; + get(): Theme { + return this.current; } /** * Sets a new value to the current theme - * * @param value + */ + set(value: string): void { + return this.changeTheme({value, isSystem: false}); + } + + /** + * Sets actual system theme and activates system theme change listener + */ + useSystem(): PromiseLike { + return this.systemThemeExtractor.getSystemTheme().then((value) => { + this.systemThemeExtractor.unsubscribe(); + this.systemThemeExtractor.subscribe( + (value: string) => { + value = this.getThemeAlias(value); + void this.changeTheme({value, isSystem: true}); + } + ); + + value = this.getThemeAlias(value); + return this.changeTheme({value, isSystem: true}); + }); + } + + /** + * Changes current theme value + * + * @param newTheme + * @throws ReferenceError * @emits `theme:change(value: string, oldValue: CanUndef)` */ - set current(value: string) { + protected changeTheme(newTheme: Theme): void { + if ( + SSR || + !Object.isString(this.themeAttribute) || + Object.fastCompare(this.current, newTheme) + ) { + return; + } + + let + {value, isSystem} = newTheme; + if (!this.availableThemes.has(value)) { - throw new ReferenceError(`A theme with the name "${value}" is not defined`); + if (!isSystem) { + throw new ReferenceError(`A theme with the name "${value}" is not defined`); + } + + value = this.defaultTheme; } - if (SSR || !Object.isString(this.themeAttribute)) { - return; + if (!isSystem) { + this.systemThemeExtractor.unsubscribe(); } - const oldValue = this.currentStore; + const oldValue = this.current; - this.currentStore = value; + this.current = newTheme; + this.themeStorage.set('colorTheme', this.current); document.documentElement.setAttribute(this.themeAttribute, value); void this.component.lfc.execCbAtTheRightTime(() => { - this.component.emit('theme:change', value, oldValue); + this.component.emit('theme:change', this.current, oldValue); }); } /** - * Initializes an event listener for changes in system appearance - * - * @param mq - * @param enabled - * @param darkTheme - * @param lightTheme + * Returns actual theme name for provided value + * @param value */ - protected initThemeListener(mq: MediaQueryList, enabled: boolean, darkTheme: string, lightTheme: string): void { - if (!enabled) { - return; + protected getThemeAlias(value: string): string { + if (prefersColorSchemeEnabled) { + return value === 'dark' ? darkThemeName : lightThemeName; } - // 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}); + return value; } } diff --git a/src/core/system-theme-extractor/CHANGELOG.md b/src/core/system-theme-extractor/CHANGELOG.md new file mode 100644 index 0000000000..f79bf5553f --- /dev/null +++ b/src/core/system-theme-extractor/CHANGELOG.md @@ -0,0 +1,16 @@ +Changelog +========= + +> **Tags:** +> - :boom: [Breaking Change] +> - :rocket: [New Feature] +> - :bug: [Bug Fix] +> - :memo: [Documentation] +> - :house: [Internal] +> - :nail_care: [Polish] + +## v4.0.0-beta.?? (2023-??-??) + +#### :rocket: New Feature + +* Module released diff --git a/src/core/system-theme-extractor/README.md b/src/core/system-theme-extractor/README.md new file mode 100644 index 0000000000..4f77b78057 --- /dev/null +++ b/src/core/system-theme-extractor/README.md @@ -0,0 +1,39 @@ +# core/system-theme-extractor + +This module provides an API for obtaining and observing the preferred color scheme of an application. + +## Usage + +By default, the engine for the web is supported. + +The engine needs to be passed to the `themeManager` constructor. + +```ts +import { webEngineFactory } from 'core/system-theme-extractor/engines/web'; + +class iRoot extends iStaticPage { + @system((o) => themeManagerFactory( + // ...other required parameters for themeManager + webEngineFactory(o) + )) + + readonly theme: CanUndef; +} +``` + +Also, you can implement your own engine. + +```ts +// src/core/system-theme-extractor/engines/custom/index.ts +import type { SystemThemeExtractor } from 'core/system-theme-extractor'; + +export default class CustomEngine implements SystemThemeExtractor { + // Implement all necessary methods of the interface here. +} +``` + +The `SystemThemeExtractor` interface specifies that the `getSystemTheme` method +should return a promise-like object so that you can compute the system theme asynchronously. +If synchronous computation is relevant in your case, you can use `SyncPromise`. + +See `components/super/i-static-page/modules/theme` for details diff --git a/src/core/system-theme-extractor/engines/web/engine.ts b/src/core/system-theme-extractor/engines/web/engine.ts new file mode 100644 index 0000000000..8bbab6b09b --- /dev/null +++ b/src/core/system-theme-extractor/engines/web/engine.ts @@ -0,0 +1,60 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import symbolGenerator from 'core/symbol'; + +import type { SystemThemeExtractor } from 'core/system-theme-extractor'; +import Friend from 'components/friends/friend'; +import SyncPromise from 'core/promise/sync'; + +const + $$ = symbolGenerator(); + +/** + * Represents a `SystemThemeExtractor` implementation tailored for web environments. + * This implementation uses a media query to monitor changes in the preferred color scheme. + */ +export default class WebEngine extends Friend implements SystemThemeExtractor { + /** + * Media query to watch theme changes + */ + protected darkThemeMq?: MediaQueryList; + + /** @inheritDoc */ + getSystemTheme(): SyncPromise { + const darkThemeMq = globalThis.matchMedia('(prefers-color-scheme: dark)'); + + return SyncPromise.resolve(darkThemeMq.matches ? 'dark' : 'light'); + } + + /** @inheritDoc */ + subscribe(cb: (value: string) => void): void { + if (this.darkThemeMq != null) { + return; + } + + this.darkThemeMq = globalThis.matchMedia('(prefers-color-scheme: dark)'); + + // TODO: understand why cant we use `this.async.on(mq, 'change', ...)`; https://github.com/V4Fire/Core/issues/369 + this.darkThemeMq.onchange = this.ctx.async.proxy((event: MediaQueryListEvent) => + cb(event.matches ? 'dark' : 'light'), + {single: false, label: $$.themeChange}); + } + + /** @inheritDoc */ + unsubscribe(): void { + if (this.darkThemeMq == null) { + return; + } + + this.darkThemeMq.onchange = null; + delete this.darkThemeMq; + + this.ctx.async.clearProxy({label: $$.themeChange}); + } +} diff --git a/src/core/system-theme-extractor/engines/web/index.ts b/src/core/system-theme-extractor/engines/web/index.ts new file mode 100644 index 0000000000..91f5d650bc --- /dev/null +++ b/src/core/system-theme-extractor/engines/web/index.ts @@ -0,0 +1,18 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +import WebEngine from 'core/system-theme-extractor/engines/web/engine'; +import type iBlock from 'components/super/i-block/i-block'; +import type { SystemThemeExtractor } from 'core/system-theme-extractor'; + +/** + * Factory for creating web engine instances + * + * @param ctx + */ +export const webEngineFactory = (ctx: iBlock): SystemThemeExtractor => new WebEngine(ctx); diff --git a/src/core/system-theme-extractor/index.ts b/src/core/system-theme-extractor/index.ts new file mode 100644 index 0000000000..64bf69abf8 --- /dev/null +++ b/src/core/system-theme-extractor/index.ts @@ -0,0 +1,9 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +export * from 'core/system-theme-extractor/interface'; diff --git a/src/core/system-theme-extractor/interface.ts b/src/core/system-theme-extractor/interface.ts new file mode 100644 index 0000000000..84a5782086 --- /dev/null +++ b/src/core/system-theme-extractor/interface.ts @@ -0,0 +1,30 @@ +/*! + * V4Fire Client Core + * https://github.com/V4Fire/Client + * + * Released under the MIT license + * https://github.com/V4Fire/Client/blob/master/LICENSE + */ + +/** + * API for retrieving and monitoring the system's visual appearance. + */ +export interface SystemThemeExtractor { + /** + * Retrieves the current system visual appearance theme. + */ + getSystemTheme(): PromiseLike; + + /** + * Initializes an event listener for changes in the system's visual appearance theme. + * + * @param cb - A callback function to be invoked when the theme changes. + * It receives the color scheme identifier as a string parameter by which project theme can be selected + */ + subscribe(cb: (value: string) => void): void; + + /** + * Terminates the event listener for changes in the system's visual appearance theme. + */ + unsubscribe(): void; +}