feat(theme): extend ThemeProvider with contrast, density, accent, fontSize
This commit is contained in:
parent
e32ff181f5
commit
50482a01fd
3 changed files with 132 additions and 26 deletions
|
|
@ -1,6 +1,16 @@
|
|||
import { createContext, useContext, useEffect, useState } from 'react';
|
||||
|
||||
export type Theme = 'dark' | 'light' | 'system';
|
||||
export type Contrast = 'normal' | 'high';
|
||||
export type Density = 'comfortable' | 'compact' | 'cozy';
|
||||
|
||||
const STORAGE_KEYS = {
|
||||
theme: 'vite-ui-theme',
|
||||
contrast: 'veza-contrast',
|
||||
density: 'veza-density',
|
||||
accentHue: 'veza-accent-hue',
|
||||
fontSize: 'veza-font-size',
|
||||
} as const;
|
||||
|
||||
type ThemeProviderProps = {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -11,76 +21,150 @@ type ThemeProviderProps = {
|
|||
type ThemeProviderState = {
|
||||
theme: Theme;
|
||||
setTheme: (theme: Theme) => void;
|
||||
contrast: Contrast;
|
||||
setContrast: (contrast: Contrast) => void;
|
||||
density: Density;
|
||||
setDensity: (density: Density) => void;
|
||||
accentHue: number;
|
||||
setAccentHue: (hue: number) => void;
|
||||
fontSize: number;
|
||||
setFontSize: (size: number) => void;
|
||||
};
|
||||
|
||||
const initialState: ThemeProviderState = {
|
||||
theme: 'system',
|
||||
setTheme: () => null,
|
||||
contrast: 'normal',
|
||||
setContrast: () => null,
|
||||
density: 'comfortable',
|
||||
setDensity: () => null,
|
||||
accentHue: 220,
|
||||
setAccentHue: () => null,
|
||||
fontSize: 16,
|
||||
setFontSize: () => null,
|
||||
};
|
||||
|
||||
const ThemeContext = createContext<ThemeProviderState>(initialState);
|
||||
|
||||
function loadStored<T>(key: string, defaultValue: T, parse: (s: string) => T): T {
|
||||
try {
|
||||
const stored = localStorage.getItem(key);
|
||||
if (stored != null) return parse(stored);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return defaultValue;
|
||||
}
|
||||
|
||||
export function ThemeProvider({
|
||||
children,
|
||||
defaultTheme = 'system',
|
||||
storageKey = 'vite-ui-theme',
|
||||
storageKey = STORAGE_KEYS.theme,
|
||||
}: ThemeProviderProps) {
|
||||
const [theme, setTheme] = useState<Theme>(
|
||||
const [theme, setThemeState] = useState<Theme>(
|
||||
() => (localStorage.getItem(storageKey) as Theme) || defaultTheme
|
||||
);
|
||||
const [contrast, setContrastState] = useState<Contrast>(
|
||||
() => loadStored(STORAGE_KEYS.contrast, 'normal', (s) => (s === 'high' ? 'high' : 'normal'))
|
||||
);
|
||||
const [density, setDensityState] = useState<Density>(
|
||||
() =>
|
||||
loadStored(STORAGE_KEYS.density, 'comfortable', (s) =>
|
||||
s === 'compact' || s === 'cozy' ? s : 'comfortable'
|
||||
)
|
||||
);
|
||||
const [accentHue, setAccentHueState] = useState<number>(
|
||||
() => loadStored(STORAGE_KEYS.accentHue, 220, (s) => Math.min(360, Math.max(0, parseInt(s, 10) || 220)))
|
||||
);
|
||||
const [fontSize, setFontSizeState] = useState<number>(
|
||||
() => loadStored(STORAGE_KEYS.fontSize, 16, (s) => Math.min(20, Math.max(14, parseInt(s, 10) || 16)))
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
|
||||
// SUMI: Use data-theme attribute instead of class toggle
|
||||
if (theme === 'system') {
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
.matches
|
||||
? 'dark'
|
||||
: 'light';
|
||||
|
||||
const systemTheme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
root.setAttribute('data-theme', systemTheme);
|
||||
return;
|
||||
}
|
||||
|
||||
root.setAttribute('data-theme', theme);
|
||||
}, [theme]);
|
||||
|
||||
// Listen for system changes when theme is 'system'
|
||||
useEffect(() => {
|
||||
if (theme !== 'system') return;
|
||||
|
||||
const mediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
|
||||
const handleChange = () => {
|
||||
const root = window.document.documentElement;
|
||||
root.setAttribute('data-theme', mediaQuery.matches ? 'dark' : 'light');
|
||||
};
|
||||
|
||||
mediaQuery.addEventListener('change', handleChange);
|
||||
return () => mediaQuery.removeEventListener('change', handleChange);
|
||||
}, [theme]);
|
||||
|
||||
const value = {
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.setAttribute('data-contrast', contrast);
|
||||
}, [contrast]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.setAttribute('data-density', density);
|
||||
}, [density]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.style.setProperty('--sumi-accent', `hsl(${accentHue}, 55%, 55%)`);
|
||||
root.style.setProperty('--sumi-accent-hover', `hsl(${accentHue}, 55%, 62%)`);
|
||||
root.style.setProperty('--sumi-accent-active', `hsl(${accentHue}, 55%, 48%)`);
|
||||
root.style.setProperty('--sumi-accent-muted', `hsl(${accentHue}, 55%, 55%, 0.2)`);
|
||||
root.style.setProperty('--sumi-accent-subtle', `hsl(${accentHue}, 55%, 55%, 0.12)`);
|
||||
root.style.setProperty('--sumi-accent-emphasis', `hsl(${accentHue}, 55%, 45%)`);
|
||||
root.style.setProperty('--primary', `hsl(${accentHue}, 55%, 55%)`);
|
||||
root.style.setProperty('--sumi-border-focus', `hsl(${accentHue}, 55%, 55%, 0.5)`);
|
||||
root.style.setProperty('--sumi-border-accent', `hsl(${accentHue}, 55%, 55%, 0.3)`);
|
||||
}, [accentHue]);
|
||||
|
||||
useEffect(() => {
|
||||
const root = window.document.documentElement;
|
||||
root.style.setProperty('--sumi-font-size-base', `${fontSize}px`);
|
||||
}, [fontSize]);
|
||||
|
||||
const value: ThemeProviderState = {
|
||||
theme,
|
||||
setTheme: (theme: Theme) => {
|
||||
localStorage.setItem(storageKey, theme);
|
||||
setTheme(theme);
|
||||
setTheme: (t) => {
|
||||
localStorage.setItem(storageKey, t);
|
||||
setThemeState(t);
|
||||
},
|
||||
contrast,
|
||||
setContrast: (c) => {
|
||||
localStorage.setItem(STORAGE_KEYS.contrast, c);
|
||||
setContrastState(c);
|
||||
},
|
||||
density,
|
||||
setDensity: (d) => {
|
||||
localStorage.setItem(STORAGE_KEYS.density, d);
|
||||
setDensityState(d);
|
||||
},
|
||||
accentHue,
|
||||
setAccentHue: (h) => {
|
||||
const clamped = Math.min(360, Math.max(0, h));
|
||||
localStorage.setItem(STORAGE_KEYS.accentHue, String(clamped));
|
||||
setAccentHueState(clamped);
|
||||
},
|
||||
fontSize,
|
||||
setFontSize: (s) => {
|
||||
const clamped = Math.min(20, Math.max(14, s));
|
||||
localStorage.setItem(STORAGE_KEYS.fontSize, String(clamped));
|
||||
setFontSizeState(clamped);
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemeContext.Provider value={value}>
|
||||
{children}
|
||||
</ThemeContext.Provider>
|
||||
);
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
}
|
||||
|
||||
export const useTheme = () => {
|
||||
const context = useContext(ThemeContext);
|
||||
|
||||
if (context === undefined)
|
||||
throw new Error('useTheme must be used within a ThemeProvider');
|
||||
|
||||
if (context === undefined) throw new Error('useTheme must be used within a ThemeProvider');
|
||||
return context;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -25,6 +25,7 @@ export { useCopyToClipboard } from './useCopyToClipboard';
|
|||
export { useUnsavedChanges, useFormDirtyState } from './useUnsavedChanges';
|
||||
export { useAnimatedCounter } from './useAnimatedCounter';
|
||||
export { useSidebarNavigation } from './useSidebarNavigation';
|
||||
export { useReducedMotion } from './useReducedMotion';
|
||||
|
||||
// Hook types
|
||||
export type {
|
||||
|
|
|
|||
21
apps/web/src/hooks/useReducedMotion.ts
Normal file
21
apps/web/src/hooks/useReducedMotion.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
/**
|
||||
* Hook for prefers-reduced-motion media query
|
||||
* Returns true when the user prefers reduced motion (OS/browser setting)
|
||||
*/
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const handler = (e: MediaQueryListEvent) => setReduced(e.matches);
|
||||
mq.addEventListener('change', handler);
|
||||
return () => mq.removeEventListener('change', handler);
|
||||
}, []);
|
||||
|
||||
return reduced;
|
||||
}
|
||||
Loading…
Reference in a new issue