managarten/packages/shared-theme/src/a11y-store.svelte.ts
Till-JS 02c82c7547 feat(a11y): add accessibility settings and theme improvements
Add comprehensive accessibility support across shared packages:
- A11y store with contrast, colorblind mode, and reduce motion settings
- A11yQuickToggles and A11ySettings UI components
- PillNavigation and PillDropdown components in shared-ui
- Calendar app updates to integrate new theme/a11y features

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-02 22:56:09 +01:00

192 lines
4.6 KiB
TypeScript

import type { A11yStore, A11yStoreConfig, ContrastLevel, ColorblindMode } from './types';
import { DEFAULT_A11Y_SETTINGS, A11Y_STORAGE_KEY_SUFFIX } from './a11y-constants';
import {
getSystemReducedMotion,
createReducedMotionListener,
applyA11yAttributes,
loadA11yFromStorage,
saveA11yToStorage,
} from './a11y-utils';
import { isBrowser } from './utils';
/**
* Create an A11y store for your app
*
* @example
* ```typescript
* import { createA11yStore } from '@manacore/shared-theme';
*
* export const a11y = createA11yStore({ appId: 'myapp' });
*
* // In +layout.svelte
* onMount(() => {
* const cleanup = a11y.initialize();
* return cleanup;
* });
* ```
*/
export function createA11yStore(config: A11yStoreConfig): A11yStore {
const { appId, defaults = {} } = config;
const storageKey = `${appId}${A11Y_STORAGE_KEY_SUFFIX}`;
// Merge defaults
const defaultSettings = { ...DEFAULT_A11Y_SETTINGS, ...defaults };
// Svelte 5 runes state
let contrast = $state<ContrastLevel>(defaultSettings.contrast);
let colorblind = $state<ColorblindMode>(defaultSettings.colorblind);
let userReduceMotion = $state<boolean | null>(null); // null = use system
let systemReduceMotion = $state<boolean>(false);
// Derived: effective reduce motion
const reduceMotion = $derived(userReduceMotion !== null ? userReduceMotion : systemReduceMotion);
// Derived: whether user has explicitly set reduce motion
const reduceMotionExplicit = $derived(userReduceMotion !== null);
/**
* Apply current A11y settings to document
*/
function applySettings(): void {
applyA11yAttributes({
contrast,
colorblind,
reduceMotion,
});
saveSettings();
}
/**
* Save settings to localStorage
*/
function saveSettings(): void {
saveA11yToStorage(storageKey, {
contrast,
colorblind,
reduceMotion: userReduceMotion !== null ? userReduceMotion : false,
});
}
/**
* Set contrast level
*/
function setContrast(level: ContrastLevel): void {
if (level === contrast) return;
if (level !== 'normal' && level !== 'high') {
console.warn(`Invalid contrast level: ${level}`);
return;
}
contrast = level;
applySettings();
}
/**
* Set colorblind mode
*/
function setColorblind(mode: ColorblindMode): void {
if (mode === colorblind) return;
const validModes: ColorblindMode[] = ['none', 'deuteranopia', 'protanopia', 'monochrome'];
if (!validModes.includes(mode)) {
console.warn(`Invalid colorblind mode: ${mode}`);
return;
}
colorblind = mode;
applySettings();
}
/**
* Set reduce motion preference
*/
function setReduceMotion(reduce: boolean): void {
userReduceMotion = reduce;
applySettings();
}
/**
* Reset reduce motion to system default
*/
function resetReduceMotion(): void {
userReduceMotion = null;
applySettings();
}
/**
* Reset all A11y settings to defaults
*/
function resetAll(): void {
contrast = defaultSettings.contrast;
colorblind = defaultSettings.colorblind;
userReduceMotion = null;
applySettings();
}
/**
* Initialize A11y store
* - Loads saved preferences from localStorage
* - Sets up reduced motion listener
* - Applies initial settings
*
* @returns Cleanup function to remove listeners
*/
function initialize(): () => void {
if (!isBrowser()) {
return () => {};
}
// Get system reduced motion preference
systemReduceMotion = getSystemReducedMotion();
// Load saved preferences
const saved = loadA11yFromStorage(storageKey);
if (saved) {
if (saved.contrast && (saved.contrast === 'normal' || saved.contrast === 'high')) {
contrast = saved.contrast;
}
if (saved.colorblind) {
const validModes: ColorblindMode[] = ['none', 'deuteranopia', 'protanopia', 'monochrome'];
if (validModes.includes(saved.colorblind as ColorblindMode)) {
colorblind = saved.colorblind as ColorblindMode;
}
}
if (typeof saved.reduceMotion === 'boolean') {
userReduceMotion = saved.reduceMotion;
}
}
// Apply initial settings
applySettings();
// Listen for system reduced motion changes
const cleanup = createReducedMotionListener((reduces) => {
systemReduceMotion = reduces;
// Only re-apply if user hasn't explicitly set a preference
if (userReduceMotion === null) {
applySettings();
}
});
return cleanup;
}
return {
get contrast() {
return contrast;
},
get colorblind() {
return colorblind;
},
get reduceMotion() {
return reduceMotion;
},
get reduceMotionExplicit() {
return reduceMotionExplicit;
},
setContrast,
setColorblind,
setReduceMotion,
resetReduceMotion,
resetAll,
initialize,
};
}