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.

235 lines
6.4 KiB

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<FrontendMode, 'auto'>;
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<ActiveDom, 'apply'>;
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<Direction | null>(
options.dir === 'auto' || options.dir === undefined ? null : options.dir
);
let theme = $state(options.theme ?? DEFAULT_THEME);
let modeOverride = $state<ResolvedFrontendMode | null>(
options.mode === 'auto' || options.mode === undefined ? null : options.mode
);
let reducedMotionOverride = $state<boolean | null>(
options.reducedMotion === 'auto' || options.reducedMotion === undefined
? null
: options.reducedMotion
);
let reducedSound = $state(options.reducedSound ?? false);
let density = $state<FrontendDensity>(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();
}
};
}

Powered by TurnKey Linux.