You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
svelte-kit-vice/src/uix/eidos/active-eidos.svelte.ts

522 lines
14 KiB

import { Context } from 'runed';
import type { ActiveDom, ActiveDomStyleHost, Breakpoint, ResponsiveProp } from '$adom';
import { readActivePrefsSlot, type ActivePrefs } from '$prefs';
import type { Density } from '$libs/density';
import type { ThemeEffective } from '$libs/theme';
import { matches } from '$libs/errs';
import { getActiveUix, type ActiveUix } from '$active-uix';
import type { ActiveLangs } from '$langs';
import type { ActiveFormat } from '$format';
import { UIX_ERR_DOM_DISABLED } from '$active-uix/errors';
import {
assertValidEidosConfig,
createEidosConfigDocumentFromConfig,
getEidosColorRoleScale,
getEidosColorScale,
getEidosCssContract,
getEidosTheme,
listEidosColorRoles,
listEidosColorScales,
listEidosThemes,
readEidosConfigFromDocument,
serializeEidosConfig,
snapshotEidosConfig,
validateEidosConfig
} from './lib/config';
import { isEidosConfigDocumentLike, type EidosConfigDocument } from './lib/persistence';
import {
renderContractCss as renderEidosContractCss,
renderCssVariables as renderEidosCssVariables,
renderStaticCss as renderEidosStaticCss,
renderThemeCss as renderEidosThemeCss,
type RenderContractCssOptions,
type RenderCssVariablesOptions,
type RenderThemeCssOptions
} from './lib/render-css';
import { createThemeBaseEidosConfig } from './lib/themes/base';
import type { EidosConfigPatch } from './lib/options';
import type {
ColorRole,
ColorScale,
EidosCssContract,
EidosCssVariableMap,
EidosValidationReport,
EidosConfig,
EidosConfigSnapshot,
ThemeDefinition
} from './lib/config-types';
import { ActiveEidosConfigError, ActiveEidosNoContextError } from './errors';
export { ActiveEidosConfigError, ActiveEidosNoContextError } from './errors';
export interface ActiveEidosThemeContext {
readonly theme: string;
readonly mode: ThemeEffective;
readonly density: Density;
}
export type ActiveEidosThemeResolver = (
context: ActiveEidosThemeContext,
active: ActiveEidos
) => string;
export interface ActiveEidosPreferenceSource {
getTheme(): string;
getMode(): ThemeEffective;
getDensity(): Density;
onPreferenceChange(handler: () => void): () => void;
}
export type ActiveEidosStyleHost = ActiveDomStyleHost;
export type ActiveEidosThemeSource = 'auto' | 'config' | 'css';
export type ActiveEidosCssVariablesOptions = Omit<RenderCssVariablesOptions, 'contract'>;
export interface ActiveEidosOptions {
readonly config?: EidosConfig | EidosConfigDocument;
readonly themeBase?: EidosConfigPatch;
readonly uix?: ActiveUix;
readonly prefs?: ActivePrefs;
readonly langs?: ActiveLangs;
readonly format?: ActiveFormat;
readonly preferences?: ActiveEidosPreferenceSource;
readonly theme?: string;
readonly mode?: ThemeEffective;
readonly density?: Density;
readonly dom?: ActiveDom;
readonly applyDom?: boolean;
readonly styleId?: string;
readonly styleHost?: ActiveEidosStyleHost;
readonly themeSource?: ActiveEidosThemeSource;
readonly cssVariables?: EidosCssVariableMap;
readonly cssVariablesSelector?: string;
readonly cssVariablesStrict?: boolean;
readonly themeResolver?: ActiveEidosThemeResolver;
}
export type ActiveEidosUserOptions = Omit<ActiveEidosOptions, 'uix' | 'prefs' | 'langs' | 'format' | 'dom'>;
const DEFAULT_STYLE_ID = 'uix-eidos';
const DEFAULT_THEME = 'base';
const DEFAULT_MODE: ThemeEffective = 'light';
const DEFAULT_DENSITY: Density = 'comfortable';
const _ctx = new Context<ActiveEidos>('ActiveEidos');
export class ActiveEidos {
readonly #config: EidosConfig;
readonly #uix: ActiveUix | undefined;
readonly #prefs: ActivePrefs | undefined;
readonly #langs: ActiveLangs | undefined;
readonly #format: ActiveFormat | undefined;
readonly #preferences: ActiveEidosPreferenceSource;
#dom: ActiveDom | undefined;
readonly #styleId: string;
readonly #styleHost: ActiveEidosStyleHost | undefined;
readonly #themeSource: ActiveEidosThemeSource;
readonly #themeResolver: ActiveEidosThemeResolver;
readonly #applyDom: boolean;
readonly #ownedStyleIds = new Set<string>();
#cssVariables: EidosCssVariableMap | undefined;
#cssVariablesOptions: ActiveEidosCssVariablesOptions;
#unsubscribe: (() => void) | undefined;
#disposed = false;
constructor(options: ActiveEidosOptions) {
this.#config = resolveEidosConfig(options.config, options.themeBase);
this.#uix = options.uix;
this.#prefs = options.prefs ?? options.uix?.prefs;
this.#langs = options.langs ?? options.uix?.langs;
this.#format = options.format ?? options.uix?.format;
this.#preferences = resolvePreferences(options);
this.#dom = options.dom ?? options.uix?.dom;
this.#styleId = options.styleId ?? DEFAULT_STYLE_ID;
this.#styleHost = options.styleHost;
this.#themeSource = options.themeSource ?? 'auto';
this.#themeResolver = options.themeResolver ?? defaultActiveEidosThemeResolver;
this.#applyDom = options.applyDom !== false;
this.#cssVariables = options.cssVariables;
this.#cssVariablesOptions = {
selector: options.cssVariablesSelector,
strict: options.cssVariablesStrict
};
if (this.#applyDom && !this.#dom) {
throw new ActiveEidosConfigError(
'dom service is required when applyDom is enabled'
);
}
if (this.#applyDom) {
this.apply();
this.#unsubscribe = this.#preferences.onPreferenceChange(() => this.apply());
}
}
get disposed(): boolean {
return this.#disposed;
}
get uix(): ActiveUix | undefined {
return this.#uix;
}
get dom(): ActiveDom {
return this.#requireDom();
}
get langs(): ActiveLangs {
if (!this.#langs) {
throw new ActiveEidosConfigError('langs service is required by ActiveEidos components');
}
return this.#langs;
}
get format(): ActiveFormat | undefined {
return this.#format;
}
get prefs(): ActivePrefs {
if (!this.#prefs) {
throw new ActiveEidosConfigError('prefs service is required by ActiveEidos components');
}
return this.#prefs;
}
getThemeContext(): ActiveEidosThemeContext {
return {
theme: this.#preferences.getTheme(),
mode: this.#preferences.getMode(),
density: this.#preferences.getDensity()
};
}
getThemeId(): string {
return this.#themeResolver(this.getThemeContext(), this);
}
snapshot(): EidosConfigSnapshot {
return snapshotEidosConfig(this.#config);
}
validate(): EidosValidationReport {
return validateEidosConfig(this.#config);
}
assertValid(): void {
assertValidEidosConfig(this.#config);
}
listColorScales(): readonly string[] {
return listEidosColorScales(this.#config);
}
listThemes(): readonly string[] {
return listEidosThemes(this.#config);
}
getTheme(id: string): ThemeDefinition | undefined {
return getEidosTheme(this.#config, id);
}
listColorRoles(): readonly ColorRole[] {
return listEidosColorRoles();
}
getColorScale(name: string): ColorScale | undefined {
return getEidosColorScale(this.#config, name);
}
getColorRoleScale(role: ColorRole, themeId?: string): ColorScale | undefined {
return getEidosColorRoleScale(this.#config, role, themeId);
}
renderStaticCss(): string {
this.assertValid();
return renderEidosStaticCss(this.#config);
}
getCssContract(): EidosCssContract {
return getEidosCssContract(this.#config);
}
toDocument(): EidosConfigDocument {
return createEidosConfigDocumentFromConfig(this.#config);
}
serialize(): string {
return serializeEidosConfig(this.#config);
}
renderContractCss(renderOptions?: RenderContractCssOptions): string {
this.assertValid();
return renderEidosContractCss(this.#config, renderOptions);
}
renderCssVariables(
variables = this.#cssVariables ?? {},
renderOptions: ActiveEidosCssVariablesOptions = this.#cssVariablesOptions
): string {
this.assertValid();
return renderEidosCssVariables(variables, {
contract: this.getCssContract(),
...renderOptions
});
}
renderThemeCss(
themeId = this.getThemeId(),
renderOptions: RenderThemeCssOptions = { selector: ':root' }
): string {
if (this.#themeSource === 'css') return '';
if (this.#themeSource === 'auto' && !this.getTheme(themeId)) return '';
this.assertValid();
return renderEidosThemeCss(this.#config, themeId, renderOptions);
}
renderCss(themeId = this.getThemeId()): string {
const staticCss = this.renderStaticCss();
const themeCss = this.renderThemeCss(themeId);
return themeCss ? `${staticCss}\n\n${themeCss}` : staticCss;
}
resolve<T>(value: ResponsiveProp<T> | undefined): T | undefined;
resolve<T>(value: ResponsiveProp<T> | undefined, fallback: T): T;
resolve<T>(value: ResponsiveProp<T> | undefined, fallback?: T): T | undefined {
return this.dom.resolve(value) ?? fallback;
}
breakpoint(name: Breakpoint): number {
return this.dom.breakpoints.current[name];
}
isAtLeast(name: Breakpoint): boolean {
return this.dom.isAtLeast(name);
}
isBelow(name: Breakpoint): boolean {
return !this.dom.isAtLeast(name);
}
setCssVariables(
variables: EidosCssVariableMap,
renderOptions: ActiveEidosCssVariablesOptions = {}
): void {
this.#cssVariables = { ...variables };
this.#cssVariablesOptions = {
...this.#cssVariablesOptions,
...renderOptions
};
this.apply();
}
clearCssVariables(): void {
this.#cssVariables = undefined;
this.apply();
}
apply(): void {
if (this.#disposed || !this.#applyDom) return;
const host = this.#resolveStyleHost();
if (!host) return;
this.#writeStyle(host, `${this.#styleId}-static`, this.renderStaticCss());
const themeStyleId = `${this.#styleId}-theme`;
const themeCss = this.renderThemeCss();
if (themeCss) {
this.#writeStyle(host, themeStyleId, themeCss);
} else {
this.#removeStyle(host, themeStyleId);
}
const variablesStyleId = `${this.#styleId}-variables`;
const variablesCss = this.#cssVariables ? this.renderCssVariables() : '';
if (variablesCss) {
this.#writeStyle(host, variablesStyleId, variablesCss);
} else {
this.#removeStyle(host, variablesStyleId);
}
}
dispose(): void {
if (this.#disposed) return;
this.#disposed = true;
this.#unsubscribe?.();
const host = this.#applyDom ? this.#resolveStyleHost() : null;
for (const id of this.#ownedStyleIds) {
this.#dom?.removeStyle(id, host ? { host } : undefined);
}
this.#ownedStyleIds.clear();
}
#resolveStyleHost(): HTMLElement | null {
if (this.#styleHost) {
return typeof this.#styleHost === 'function' ? this.#styleHost() : this.#styleHost;
}
try {
return this.#requireDom().getDocument().head;
} catch (error) {
if (matches(error, UIX_ERR_DOM_DISABLED)) {
throw new ActiveEidosConfigError(
'dom service is disabled; pass applyDom:false or enable ActiveUix dom',
error
);
}
return null;
}
}
#writeStyle(host: HTMLElement, id: string, css: string): void {
this.#requireDom().writeStyle(id, css, {
host,
attrs: { 'data-uix-eidos': true }
});
this.#ownedStyleIds.add(id);
}
#removeStyle(host: HTMLElement, id: string): void {
this.#dom?.removeStyle(id, { host });
this.#ownedStyleIds.delete(id);
}
#requireDom(): ActiveDom {
if (!this.#dom) {
throw new ActiveEidosConfigError(
'dom service is required when applyDom is enabled'
);
}
return this.#dom;
}
static create(opts: ActiveEidosUserOptions = {}): ActiveEidos {
const uix = getActiveUix();
const instance = new ActiveEidos({
...opts,
uix,
prefs: uix.prefs,
langs: uix.langs,
format: uix.format,
dom: uix.dom
});
return _ctx.set(instance);
}
static set(active: ActiveEidos): ActiveEidos {
return _ctx.set(active);
}
static get(): ActiveEidos | undefined {
return _ctx.getOr(undefined) as ActiveEidos | undefined;
}
static require(): ActiveEidos {
const active = _ctx.getOr(undefined) as ActiveEidos | undefined;
if (!active) throw new ActiveEidosNoContextError();
return active;
}
}
export function defaultActiveEidosThemeResolver(
context: ActiveEidosThemeContext,
active: ActiveEidos
): string {
if (active.getTheme(context.theme)) return context.theme;
if (isModeQualifiedThemeId(context.theme)) return context.theme;
return `${context.theme}-${context.mode}`;
}
export function createActiveEidos(options: ActiveEidosOptions): ActiveEidos {
return new ActiveEidos(options);
}
function resolveEidosConfig(
config: EidosConfig | EidosConfigDocument | undefined,
themeBase: EidosConfigPatch | undefined
): EidosConfig {
if (!config) return createThemeBaseEidosConfig(themeBase);
if (themeBase) {
throw new ActiveEidosConfigError(
'pass either a complete config or a themeBase patch, not both'
);
}
if (isEidosConfigDocumentLike(config)) return readEidosConfigFromDocument(config);
return snapshotEidosConfig(config as EidosConfig);
}
function isModeQualifiedThemeId(themeId: string): boolean {
return /-(light|dark)$/.test(themeId);
}
function resolvePreferences(options: ActiveEidosOptions): ActiveEidosPreferenceSource {
if (options.preferences) return options.preferences;
const prefs = options.prefs ?? options.uix?.prefs;
if (prefs) {
return createPrefsPreferenceSource(prefs, {
theme: options.theme,
mode: options.mode,
density: options.density
});
}
return createStaticPreferenceSource({
theme: options.theme,
mode: options.mode,
density: options.density
});
}
function createPrefsPreferenceSource(
prefs: ActivePrefs,
defaults: {
readonly theme?: string;
readonly mode?: ThemeEffective;
readonly density?: Density;
}
): ActiveEidosPreferenceSource {
return {
getTheme: () => defaults.theme ?? DEFAULT_THEME,
getMode: () =>
readActivePrefsSlot<ThemeEffective>(prefs, 'theme')?.get() ??
defaults.mode ??
DEFAULT_MODE,
getDensity: () =>
readActivePrefsSlot<Density>(prefs, 'density')?.get() ??
defaults.density ??
DEFAULT_DENSITY,
onPreferenceChange(handler) {
const detachers = [
readActivePrefsSlot<ThemeEffective>(prefs, 'theme')?.onChange(handler),
readActivePrefsSlot<Density>(prefs, 'density')?.onChange(handler)
].filter((off): off is () => void => typeof off === 'function');
return () => {
for (const detach of detachers) detach();
};
}
};
}
function createStaticPreferenceSource(defaults: {
readonly theme?: string;
readonly mode?: ThemeEffective;
readonly density?: Density;
}): ActiveEidosPreferenceSource {
return {
getTheme: () => defaults.theme ?? DEFAULT_THEME,
getMode: () => defaults.mode ?? DEFAULT_MODE,
getDensity: () => defaults.density ?? DEFAULT_DENSITY,
onPreferenceChange: () => () => {}
};
}

Powered by TurnKey Linux.