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

610 lines
17 KiB

import { Context } from 'runed';
import type {
ActiveDom,
ActiveDomStyleHost,
Breakpoint,
DomAttrValue,
ResponsiveProp
} from '$adom';
import 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,
getEidosRecipeTokens,
getEidosTheme,
listEidosColorRoles,
listEidosColorScales,
listEidosRecipes,
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,
RecipeTokenMap,
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 interface ActiveEidosValueSource<T> {
get(): T;
onChange(handler: (value: T) => void): () => void;
}
interface ActiveEidosLastAttrs {
readonly target: HTMLElement;
readonly themeId: string;
readonly mode: ThemeEffective;
readonly density: Density;
}
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 modeSource?: ActiveEidosValueSource<ThemeEffective>;
readonly densitySource?: ActiveEidosValueSource<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 EIDOS_THEME_ATTR = 'data-theme';
const EIDOS_MODE_ATTR = 'data-mode';
const EIDOS_DENSITY_ATTR = 'data-density';
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;
#lastAttrs: ActiveEidosLastAttrs | 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 ? { ...options.cssVariables } : undefined;
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.#cssVariables) this.renderCssVariables(this.#cssVariables, this.#cssVariablesOptions);
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);
}
listRecipes(): readonly string[] {
return listEidosRecipes(this.#config);
}
getTheme(id: string): ThemeDefinition | undefined {
return getEidosTheme(this.#config, id);
}
getRecipeTokens(component: string): RecipeTokenMap | undefined {
return getEidosRecipeTokens(this.#config, component);
}
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 {
const nextVariables = { ...variables };
const nextOptions = {
...this.#cssVariablesOptions,
...renderOptions
};
this.renderCssVariables(nextVariables, nextOptions);
this.#cssVariables = nextVariables;
this.#cssVariablesOptions = nextOptions;
this.apply();
}
clearCssVariables(): void {
this.#cssVariables = undefined;
this.apply();
}
apply(): void {
if (this.#disposed || !this.#applyDom) return;
const host = this.#resolveStyleHost();
if (!host) return;
const themeContext = this.getThemeContext();
const themeId = this.#themeResolver(themeContext, this);
const staticCss = this.renderStaticCss();
const themeStyleId = `${this.#styleId}-theme`;
const themeCss = this.renderThemeCss(themeId);
const variablesStyleId = `${this.#styleId}-variables`;
const variablesCss = this.#cssVariables ? this.renderCssVariables() : '';
this.#writeThemeAttrs(themeId, themeContext.mode, themeContext.density);
this.#writeStyle(host, `${this.#styleId}-static`, staticCss);
if (themeCss) {
this.#writeStyle(host, themeStyleId, themeCss);
} else {
this.#removeStyle(host, themeStyleId);
}
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();
if (this.#lastAttrs) {
const { target, themeId, mode, density } = this.#lastAttrs;
const attrs: Record<string, DomAttrValue> = {};
if (target.getAttribute(EIDOS_THEME_ATTR) === themeId) {
attrs[EIDOS_THEME_ATTR] = undefined;
}
if (target.getAttribute(EIDOS_MODE_ATTR) === mode) attrs[EIDOS_MODE_ATTR] = undefined;
if (target.getAttribute(EIDOS_DENSITY_ATTR) === density) {
attrs[EIDOS_DENSITY_ATTR] = undefined;
}
this.#dom?.apply({
target,
attrs
});
}
this.#lastAttrs = undefined;
}
#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);
}
#writeThemeAttrs(themeId: string, mode: ThemeEffective, density: Density): void {
const dom = this.#requireDom();
let target: HTMLElement;
try {
target = dom.getDocument().documentElement;
} 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;
}
dom.apply({
target,
attrs: {
[EIDOS_THEME_ATTR]: themeId,
[EIDOS_MODE_ATTR]: mode,
[EIDOS_DENSITY_ATTR]: density
} satisfies Record<string, DomAttrValue>
});
this.#lastAttrs = { target, themeId, mode, density };
}
#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;
return createComposedPreferenceSource({
theme: options.theme,
modeSource:
options.modeSource ??
(options.mode ? createStaticValueSource(options.mode) : createSystemColorSchemeSource()),
densitySource:
options.densitySource ?? createStaticValueSource(options.density ?? DEFAULT_DENSITY)
});
}
function createComposedPreferenceSource(options: {
readonly theme?: string;
readonly modeSource: ActiveEidosValueSource<ThemeEffective>;
readonly densitySource: ActiveEidosValueSource<Density>;
}): ActiveEidosPreferenceSource {
return {
getTheme: () => options.theme ?? DEFAULT_THEME,
getMode: () => options.modeSource.get(),
getDensity: () => options.densitySource.get(),
onPreferenceChange(handler) {
const detachers = [
options.modeSource.onChange(handler),
options.densitySource.onChange(handler)
];
return () => {
for (const detach of detachers) detach();
};
}
};
}
function createStaticValueSource<T>(value: T): ActiveEidosValueSource<T> {
return {
get: () => value,
onChange: () => () => {}
};
}
function createSystemColorSchemeSource(
fallback: ThemeEffective = DEFAULT_MODE
): ActiveEidosValueSource<ThemeEffective> {
const media = getColorSchemeMedia();
return {
get: () => (media ? (media.matches ? 'dark' : 'light') : fallback),
onChange(handler) {
if (!media) return () => {};
const listener = () => handler(media.matches ? 'dark' : 'light');
if (typeof media.addEventListener === 'function') {
media.addEventListener('change', listener);
return () => media.removeEventListener('change', listener);
}
media.addListener?.(listener);
return () => media.removeListener?.(listener);
}
};
}
function getColorSchemeMedia(): MediaQueryList | undefined {
if (typeof globalThis.matchMedia !== 'function') return undefined;
return globalThis.matchMedia('(prefers-color-scheme: dark)');
}

Powered by TurnKey Linux.