UNPKG

@aesthetic/core

Version:

Aesthetic is a powerful type-safe, framework agnostic, CSS-in-JS library for styling components.

494 lines (408 loc) 12.6 kB
/* eslint-disable no-param-reassign, unicorn/prefer-prototype-methods */ import { Theme, ThemeRegistry } from '@aesthetic/system'; import { ClassName, Direction, Engine, FontFace, Import, Keyframes, RenderOptions, Rule, ThemeName, } from '@aesthetic/types'; import { arrayLoop, isDOM, objectLoop } from '@aesthetic/utils'; import { FeatureSheet } from './FeatureSheet'; import { renderComponent, renderTheme } from './renderers'; import { Sheet } from './Sheet'; import { AestheticOptions, ComponentSheet, ComponentSheetFactory, ElementSheetFactory, EventListener, EventType, InferKeys, InferKeysFromSheets, OnChangeDirection, OnChangeTheme, ResultGenerator, SheetParams, SheetRenderResult, ThemeSheet, ThemeSheetFactory, } from './types'; export class Aesthetic<Input extends object = Rule, Output = ClassName> { protected activeDirection?: Direction; protected activeTheme?: ThemeName; protected globalSheetRegistry = new Map<ThemeName, ThemeSheet<string, Input, Output>>(); protected listeners = new Map<EventType, Set<EventListener>>(); protected options: AestheticOptions = {}; protected styleEngine?: Engine<Input, Output>; protected themeRegistry = new ThemeRegistry<Input>(); constructor(options?: AestheticOptions) { if (options) { this.configure(options); } // Must be bound manually because of method overloading this.emit = this.emit.bind(this); this.subscribe = this.subscribe.bind(this); this.unsubscribe = this.unsubscribe.bind(this); } /** * Change the active direction. */ changeDirection = (direction: Direction, propagate: boolean = true): void => { if (direction === this.activeDirection) { return; } const engine = this.getEngine(); // Set the active direction this.activeDirection = direction; // Update engine direction engine.direction = direction; engine.setDirection(direction); // Let consumers know about the change if (propagate) { this.emit('change:direction', [direction]); } }; /** * Change the active theme. */ changeTheme = (name: ThemeName, propagate: boolean = true): void => { if (name === this.activeTheme) { return; } const theme = this.getTheme(name); const engine = this.getEngine(); // Set the active theme this.activeTheme = name; // Set root variables if (this.options.rootVariables) { engine.setRootVariables(theme.toVariables()); } // Render theme styles const results = this.renderThemeSheet(theme); // Update engine theme engine.setTheme(results); // Let consumers know about the change if (propagate) { this.emit('change:theme', [name, results]); } }; /** * Configure options unique to this instance. */ configure = (options: AestheticOptions): void => { Object.assign(this.options, options); // Configure the engine with itself to reapply options if (this.styleEngine) { this.configureEngine(this.styleEngine); } }; /** * Configuring which styling engine to use. */ configureEngine = (engine: Engine<Input, Output>): void => { const { options } = this; if (options.customProperties) { engine.customProperties = options.customProperties; } if (options.directionConverter) { engine.directionConverter = options.directionConverter; } if (options.defaultUnit) { engine.unitSuffixer = options.defaultUnit; } if (options.vendorPrefixer) { engine.vendorPrefixer = options.vendorPrefixer; } this.styleEngine = engine; }; /** * Create a style sheet that supports multiple elements, * for use within components. */ createStyleSheet = <T = unknown>( factory: ComponentSheetFactory<T, Input>, ): ComponentSheet<InferKeys<keyof T>, Input, Output> => new FeatureSheet<Input, Output, ComponentSheetFactory<T, Input>>(factory, renderComponent); /** * Create a style sheet scoped for a single element. */ createScopedStyleSheet = <K extends string = 'element'>( factory: ElementSheetFactory<Input> | Input, selector?: K, ) => this.createStyleSheet<Record<K, Input>>((utils) => ({ [selector ?? 'element']: typeof factory === 'function' ? factory(utils) : factory, })); /** * Create a global style sheet for root theme styles. */ createThemeSheet = <T = unknown>( factory: ThemeSheetFactory<T, Input>, ): ThemeSheet<InferKeys<keyof T>, Input, Output> => new Sheet(factory, renderTheme); /** * Emit all listeners by type, with the defined arguments. */ emit(type: 'change:direction', args: Parameters<OnChangeDirection>): void; emit(type: 'change:theme', args: Parameters<OnChangeTheme>): void; emit(type: EventType, args: unknown[]): void { this.getListeners(type).forEach((listener) => { listener(...args); }); } /** * Generate a list of results using the selectors of a style sheet. * If a set is provided, it will be used to check for variants. */ generateResults: ResultGenerator<string, Output> = (args, variants, renderResult) => { const output: Output[] = []; arrayLoop(args, (arg) => { if (!arg) { return; } // Custom results being passes if (Array.isArray(arg)) { output.push(...(arg as Output[]).filter(Boolean)); return; } const item = renderResult[arg]; if (item?.result) { output.push(item?.result); } arrayLoop(item?.variants, ({ types, result }) => { if (types.every((type) => variants.has(type))) { output.push(result); } }); }); return output; }; /** * Return the active direction for the entire application. If an active direction is undefined, * it will be detected from the browser's `dir` attribute. */ getActiveDirection = (): Direction => { if (this.activeDirection) { return this.activeDirection; } // Detect theme from engine const direction: Direction = this.getEngine().direction || 'ltr'; this.changeDirection(direction); return direction; }; /** * Return the currently active theme instance. If an active instance has not been defined, * one will be detected from the client's browser preferences. */ getActiveTheme = (): Theme<Input> => { if (this.activeTheme) { return this.getTheme(this.activeTheme); } // Detect theme from browser preferences const engine = this.getEngine(); const theme = this.themeRegistry.getPreferredTheme({ matchColorScheme: engine.prefersColorScheme, matchContrastLevel: engine.prefersContrastLevel, }); this.changeTheme(theme.name); return theme; }; /** * Return the current style engine. */ getEngine = (): Engine<Input, Output> => { const current = (typeof global !== 'undefined' && global.AESTHETIC_CUSTOM_ENGINE) || this.styleEngine; if (current) { return current; } throw new Error('No style engine defined. Have you configured one with `configureEngine()`?'); }; /** * Return a set of listeners, or create it if it does not exist. */ getListeners = <T extends Function>(type: EventType): Set<T> => { const set = this.listeners.get(type) ?? new Set(); this.listeners.set(type, set); return set as unknown as Set<T>; }; /** * Return a theme instance by name. */ getTheme = (name: ThemeName): Theme<Input> => this.themeRegistry.getTheme(name); /** * Merge multiple sheets into a single sheet and inherit all overrides. */ mergeStyleSheets = <T extends ComponentSheet<string, Input, Output>[]>( ...sheets: T ): ComponentSheet<InferKeysFromSheets<T>, Input, Output> => { if (sheets.length === 1) { return sheets[0]; } const mergedSheet = new FeatureSheet(sheets[0].factory, sheets[0].renderer); arrayLoop(sheets, (sheet, index) => { if (index > 0) { mergedSheet.addOverride(sheet.factory); } // @ts-expect-error Allow access arrayLoop(sheet.overrides, (override) => { mergedSheet.addOverride(override); }); // @ts-expect-error Allow access objectLoop(sheet.contrastOverrides, (overrides, contrast) => { arrayLoop(overrides, (override) => { mergedSheet.addContrastOverride(contrast, override); }); }); // @ts-expect-error Allow access objectLoop(sheet.schemeOverrides, (overrides, scheme) => { arrayLoop(overrides, (override) => { mergedSheet.addColorSchemeOverride(scheme, override); }); }); // @ts-expect-error Allow access objectLoop(sheet.themeOverrides, (overrides, theme) => { arrayLoop(overrides, (override) => { mergedSheet.addThemeOverride(theme, override); }); }); }); return mergedSheet; }; /** * Register a theme, with optional global theme styles. */ registerTheme = ( name: ThemeName, theme: Theme<Input>, sheet: ThemeSheet<string, Input, Output> | null = null, isDefault: boolean = false, ): void => { this.themeRegistry.register(name, theme, isDefault); if (sheet) { if (__DEV__ && !(sheet instanceof Sheet)) { throw new TypeError('Rendering theme styles require a `Sheet` instance.'); } this.globalSheetRegistry.set(name, sheet); } }; /** * Register a default light or dark theme, with optional global theme styles. */ registerDefaultTheme = ( name: ThemeName, theme: Theme<Input>, sheet: ThemeSheet<string, Input, Output> | null = null, ): void => { this.registerTheme(name, theme, sheet, true); }; /** * Render a `@font-face` to the global style sheet and return the font family name. */ renderFontFace = (fontFace: FontFace, fontFamily?: string, params?: RenderOptions): string => this.getEngine().renderFontFace( { fontFamily, ...fontFace, }, params, ); /** * Render an `@import` to the global style sheet and return the import path. */ renderImport = (path: Import | string, params?: RenderOptions): string => this.getEngine().renderImport(path, params); /** * Render a `@keyframes` to the global style sheet and return the animation name. */ renderKeyframes = ( keyframes: Keyframes, animationName?: string, params?: RenderOptions, ): string => this.getEngine().renderKeyframes(keyframes, animationName, params); /** * Render a style rule outside of the context of a style sheet. */ renderStyles = (rule: Input) => this.getEngine().renderRule(rule, this.getRenderOptions()); /** * Render a component style sheet to the document with the defined style query parameters. */ renderStyleSheet = <T = unknown>( sheet: ComponentSheet<T, Input, Output>, params: SheetParams = {}, ) => { if (__DEV__ && !(sheet instanceof Sheet)) { throw new TypeError('Rendering component styles require a `Sheet` instance.'); } return this.renderSheet<InferKeys<T>>( sheet, params.theme ? this.getTheme(params.theme) : this.getActiveTheme(), params, ); }; /** * Render a theme style sheet and return a result, if one was generated. */ renderThemeSheet = (theme: Theme<Input>, params: SheetParams = {}): Output[] => { const sheet = this.globalSheetRegistry.get(theme.name); const results: Output[] = []; // Render theme CSS variables if (isDOM()) { results.push( this.getEngine().renderRuleGrouped({ '@variables': theme.toVariables() } as Input, { type: 'global', }).result, ); } // Render theme styles if (sheet) { const result = this.renderSheet<'root'>(sheet, theme, params).root?.result; if (result) { results.push(result); } } return results; }; /** * Subscribe and listen to an event by name. */ subscribe(type: 'change:direction', listener: OnChangeDirection): () => void; subscribe(type: 'change:theme', listener: OnChangeTheme): () => void; subscribe(type: EventType, listener: Function): () => void { this.getListeners(type).add(listener); return () => { this.unsubscribe(type as 'change:theme', listener as OnChangeTheme); }; } /** * Unsubscribe from an event by name. */ unsubscribe(type: 'change:direction', listener: OnChangeDirection): void; unsubscribe(type: 'change:theme', listener: OnChangeTheme): void; unsubscribe(type: EventType, listener: Function): void { this.getListeners(type).delete(listener); } protected getRenderOptions(options?: RenderOptions) { return { deterministic: !!this.options.deterministicClasses, direction: this.getActiveDirection(), vendor: !!this.options.vendorPrefixer, ...options, }; } protected renderSheet<Keys>( sheet: ComponentSheet<string, Input, Output> | ThemeSheet<string, Input, Output>, theme: Theme<Input>, params: SheetParams, ): SheetRenderResult<Output, Keys> { return sheet.render( this.getEngine(), theme, this.getRenderOptions(params), ) as SheetRenderResult<Output, Keys>; } }