import type { LocaleSource } from '$locale'; import { createActiveDom, type ActiveDom } from '$adom'; import { DEFAULT_DENSITY, DEFAULT_MODE, DEFAULT_THEME, FRONTEND_ATTRS } from './consts'; import { resolveDir } from './locale-defaults'; import type { Direction } from './locale-defaults'; export type FrontendMode = 'light' | 'dark' | 'auto'; export type ResolvedFrontendMode = Exclude; export type FrontendDensity = 'compact' | 'normal' | 'comfortable'; export type FrontendDirMode = Direction | 'auto'; /** * Reactive locale provider consumed by `ActiveFrontend`. Alias of the shared * `LocaleSource` so existing imports keep working. */ export type FrontendLocaleSource = LocaleSource; export type FrontendTarget = HTMLElement | (() => HTMLElement | null); export type FrontendDom = Pick; export interface ActiveFrontendOptions { locale?: string; localeSource?: FrontendLocaleSource; dom?: FrontendDom; target?: FrontendTarget; applyDom?: boolean; /** Initial dir override (`auto` = derive from locale). */ dir?: FrontendDirMode; theme?: string; mode?: FrontendMode; reducedMotion?: boolean | 'auto'; reducedSound?: boolean; density?: FrontendDensity; } export interface ActiveFrontend { getLocale: () => string; setLocale: (locale: string) => void; getDir: () => Direction; setDir: (dir: FrontendDirMode) => void; clearDir: () => void; isDirAuto: () => boolean; getTheme: () => string; setTheme: (theme: string) => void; getMode: () => ResolvedFrontendMode; setMode: (mode: FrontendMode) => void; clearMode: () => void; isModeAuto: () => boolean; getReducedMotion: () => boolean; setReducedMotion: (reduced: boolean | 'auto') => void; clearReducedMotion: () => void; isReducedMotionAuto: () => boolean; getReducedSound: () => boolean; setReducedSound: (reduced: boolean) => void; getDensity: () => FrontendDensity; setDensity: (density: FrontendDensity) => void; onPreferenceChange: (fn: () => void) => () => void; dispose: () => void; } function getDefaultTarget(): HTMLElement | null { return typeof document === 'undefined' ? null : document.documentElement; } function resolveTarget(target?: FrontendTarget): HTMLElement | null { if (target === undefined) return getDefaultTarget(); return typeof target === 'function' ? target() : target; } function subscribeMedia(query: string, fn: (matches: boolean) => void): () => void { if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return () => {}; const media = window.matchMedia(query); const onChange = (event: MediaQueryListEvent): void => fn(event.matches); fn(media.matches); media.addEventListener('change', onChange); return () => media.removeEventListener('change', onChange); } export function createActiveFrontend(options: ActiveFrontendOptions = {}): ActiveFrontend { const localeSource = options.localeSource; const ownedDom = options.applyDom === false || options.dom !== undefined ? undefined : createActiveDom(); const dom = options.applyDom === false ? undefined : (options.dom ?? ownedDom); let currentLocale = $state(localeSource?.getLocale() ?? options.locale ?? ''); let dirOverride = $state( options.dir === 'auto' || options.dir === undefined ? null : options.dir ); let theme = $state(options.theme ?? DEFAULT_THEME); let modeOverride = $state( options.mode === 'auto' || options.mode === undefined ? null : options.mode ); let reducedMotionOverride = $state( options.reducedMotion === 'auto' || options.reducedMotion === undefined ? null : options.reducedMotion ); let reducedSound = $state(options.reducedSound ?? false); let density = $state(options.density ?? DEFAULT_DENSITY); let osDarkMode = $state(false); let osReducedMotion = $state(false); const listeners = new Set<() => void>(); function getLocale(): string { return currentLocale || localeSource?.getLocale() || options.locale || ''; } function getDir(): Direction { return dirOverride ?? resolveDir(getLocale()); } function getMode(): ResolvedFrontendMode { return modeOverride ?? (osDarkMode ? 'dark' : DEFAULT_MODE); } function getReducedMotion(): boolean { return reducedMotionOverride ?? osReducedMotion; } function applyDom(): void { const target = resolveTarget(options.target); if (dom === undefined || target === null) return; dom.apply({ target, attrs: { [FRONTEND_ATTRS.DIR]: getDir(), [FRONTEND_ATTRS.THEME]: theme, [FRONTEND_ATTRS.MODE]: getMode(), [FRONTEND_ATTRS.REDUCED_MOTION]: String(getReducedMotion()), [FRONTEND_ATTRS.REDUCED_SOUND]: String(reducedSound), [FRONTEND_ATTRS.DENSITY]: density } }); } function notify(): void { applyDom(); for (const fn of listeners) fn(); } function setLocale(locale: string): void { currentLocale = locale; notify(); } const unsubscribeLocale = localeSource?.onLocaleChange?.(setLocale); const unsubscribeDarkMode = subscribeMedia('(prefers-color-scheme: dark)', (matches) => { osDarkMode = matches; notify(); }); const unsubscribeReducedMotion = subscribeMedia('(prefers-reduced-motion: reduce)', (matches) => { osReducedMotion = matches; notify(); }); applyDom(); return { getLocale, setLocale, getDir, setDir: (dir) => { dirOverride = dir === 'auto' ? null : dir; notify(); }, clearDir: () => { dirOverride = null; notify(); }, isDirAuto: () => dirOverride === null, getTheme: () => theme, setTheme: (nextTheme) => { theme = nextTheme; notify(); }, getMode, setMode: (mode) => { modeOverride = mode === 'auto' ? null : mode; notify(); }, clearMode: () => { modeOverride = null; notify(); }, isModeAuto: () => modeOverride === null, getReducedMotion, setReducedMotion: (reduced) => { reducedMotionOverride = reduced === 'auto' ? null : reduced; notify(); }, clearReducedMotion: () => { reducedMotionOverride = null; notify(); }, isReducedMotionAuto: () => reducedMotionOverride === null, getReducedSound: () => reducedSound, setReducedSound: (reduced) => { reducedSound = reduced; notify(); }, getDensity: () => density, setDensity: (nextDensity) => { density = nextDensity; notify(); }, onPreferenceChange: (fn) => { listeners.add(fn); return () => listeners.delete(fn); }, dispose() { unsubscribeLocale?.(); unsubscribeDarkMode(); unsubscribeReducedMotion(); ownedDom?.dispose(); listeners.clear(); } }; }