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
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();
|
|
}
|
|
};
|
|
}
|