Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@
},
"dependencies": {
"@withease/factories": "^1.0.5",
"@withease/web-api": "^1.3.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"effector": "^23.4.4",
"effector-react": "^23.3.0",
Expand Down
30 changes: 30 additions & 0 deletions src/features/theme-toggle/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { Monitor, Moon, Sun } from 'lucide-react';

export const THEME_STORAGE_KEY = 'app-theme';

export const ThemeKeys = {
dark: 'dark',
light: 'light',
system: 'system',
} as const;

export type ThemeKey = (typeof ThemeKeys)[keyof typeof ThemeKeys];
export type ResolvedTheme = Omit<ThemeKey, 'system'>;

export const THEMES: ThemeKey[] = [
ThemeKeys.light,
ThemeKeys.dark,
ThemeKeys.system,
];

export const THEME_LABELS: Record<ThemeKey, string> = {
[ThemeKeys.light]: 'Светлая',
[ThemeKeys.dark]: 'Темная',
[ThemeKeys.system]: 'Системная',
};

export const THEME_ICONS: Record<ThemeKey, typeof Sun> = {
[ThemeKeys.light]: Sun,
[ThemeKeys.dark]: Moon,
[ThemeKeys.system]: Monitor,
};
1 change: 1 addition & 0 deletions src/features/theme-toggle/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ThemeToggle } from './view';
69 changes: 69 additions & 0 deletions src/features/theme-toggle/lib.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import {
type ResolvedTheme,
THEME_STORAGE_KEY,
type ThemeKey,
ThemeKeys,
} from './config';

/**
* Получить сохраненную тему из localStorage
*/
export function getStoredTheme(): ThemeKey | null {
if (typeof globalThis.window === 'undefined') {
return null;
}

const stored = localStorage.getItem(THEME_STORAGE_KEY);
if (
stored === ThemeKeys.light ||
stored === ThemeKeys.dark ||
stored === ThemeKeys.system
) {
return stored;
}

return null;
}

/**
* Сохранить тему в localStorage
* @todo cделать как эффект
*/
export function saveTheme(theme: ThemeKey): void {
if (typeof globalThis.window === 'undefined') {
return;
}

localStorage.setItem(THEME_STORAGE_KEY, theme);
}

/**
* Разрешить тему (преобразовать 'system' в 'light' или 'dark')
*/
export function resolveTheme(
theme: ThemeKey,
prefersDarkMode: boolean,
): ResolvedTheme {
if (theme === ThemeKeys.system) {
return prefersDarkMode ? ThemeKeys.dark : ThemeKeys.light;
}

return theme;
}

/**
* Применить тему к документу (добавить/удалить класс dark на <html>)
*/
export function applyThemeToHtml(resolvedTheme: ResolvedTheme): void {
if (typeof globalThis.document === 'undefined') {
return;
}

const root = globalThis.document.documentElement;

if (resolvedTheme === ThemeKeys.dark) {
root.classList.add(ThemeKeys.dark);
} else {
root.classList.remove(ThemeKeys.dark);
}
}
71 changes: 71 additions & 0 deletions src/features/theme-toggle/model.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import { trackMediaQuery } from '@withease/web-api';
import { createEffect, createEvent, createStore, sample } from 'effector';

import { applicationStarted } from '~/shared/init';

import type { ResolvedTheme, ThemeKey } from './config';
import { ThemeKeys } from './config';
import {
applyThemeToHtml,
getStoredTheme,
resolveTheme,
saveTheme,
} from './lib';

// Интеграция с @withease/web-api для отслеживания системной темы
const { $matches: $prefersDarkMode } = trackMediaQuery(
'(prefers-color-scheme: dark)',
{
setup: applicationStarted,
},
);

export const themeChanged = createEvent<ThemeKey>();

const applyThemeFx = createEffect<ResolvedTheme, void>(resolvedTheme => {
applyThemeToHtml(resolvedTheme);
});

export const $theme = createStore<ThemeKey>(ThemeKeys.system);
export const $resolvedTheme = createStore<ResolvedTheme>(ThemeKeys.light);

// Инициализация темы при старте приложения
sample({
clock: applicationStarted,
fn: () => {
const stored = getStoredTheme();
return stored ?? ThemeKeys.system;
},
target: $theme,
});

// Сохранение темы в localStorage при изменении
sample({
clock: themeChanged,
fn: theme => {
saveTheme(theme);
return theme;
},
target: $theme,
});

// Разрешение темы на основе $theme и системных настроек
sample({
clock: [$theme, $prefersDarkMode],
source: { theme: $theme, prefersDarkMode: $prefersDarkMode },
fn: ({ theme, prefersDarkMode }) => {
const resolved = resolveTheme(theme, prefersDarkMode);
// Синхронное применение при инициализации для предотвращения мигания
if (typeof globalThis.window !== 'undefined') {
applyThemeToHtml(resolved);
}
return resolved;
},
target: $resolvedTheme,
});

// Применение темы к HTML при изменении resolvedTheme
sample({
clock: $resolvedTheme,
target: applyThemeFx,
});
35 changes: 35 additions & 0 deletions src/features/theme-toggle/view.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useUnit } from 'effector-react';

import { cn } from '~/shared/lib/cn';

import { THEMES, THEME_ICONS, THEME_LABELS, type ThemeKey } from './config';
import { $theme, themeChanged } from './model';

export function ThemeToggle() {
const [theme, changeTheme] = useUnit([$theme, themeChanged]);

const Icon = THEME_ICONS[theme];

return (
<div className="flex items-center gap-2">
<Icon className="text-text-secondary size-4 shrink-0" />

<select
value={theme}
aria-label="Выбор темы"
onChange={event => changeTheme(event.target.value as ThemeKey)}
className={cn(
'border-border-default text-text-primary h-8 rounded-md border bg-transparent px-2 text-sm transition-colors',
'hover:border-border-strong focus:border-primary focus:ring-primary/20 focus:ring-2 focus:outline-none',
'cursor-pointer',
)}
>
{THEMES.map(themeOption => (
<option key={themeOption} value={themeOption}>
{THEME_LABELS[themeOption]}
</option>
))}
</select>
</div>
);
}
Loading