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>
This commit is contained in:
Till-JS 2025-12-02 22:56:09 +01:00
parent 6cc9f70a4a
commit 02c82c7547
33 changed files with 1474 additions and 143 deletions

View file

@ -0,0 +1,312 @@
import type {
ThemeColors,
EffectiveMode,
HSLValue,
ContrastLevel,
ColorblindMode,
A11ySettings,
} from './types';
import { parseHSL, createHSL, isBrowser } from './utils';
import { HIGH_CONTRAST_CONFIG, COLORBLIND_TRANSFORMS, MOTION_DEFAULTS } from './a11y-constants';
// ============================================================================
// Reduced Motion
// ============================================================================
/**
* Check if system prefers reduced motion
*/
export function getSystemReducedMotion(): boolean {
if (!isBrowser()) return false;
return window.matchMedia('(prefers-reduced-motion: reduce)').matches;
}
/**
* Create a media query listener for reduced motion preference changes
*/
export function createReducedMotionListener(callback: (reduces: boolean) => void): () => void {
if (!isBrowser()) return () => {};
const mediaQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
const handler = (e: MediaQueryListEvent) => callback(e.matches);
if (mediaQuery.addEventListener) {
mediaQuery.addEventListener('change', handler);
return () => mediaQuery.removeEventListener('change', handler);
}
mediaQuery.addListener(handler);
return () => mediaQuery.removeListener(handler);
}
/**
* Apply motion settings to document
*/
export function applyMotionSettings(reduceMotion: boolean): void {
if (!isBrowser()) return;
const root = document.documentElement;
if (reduceMotion) {
root.setAttribute('data-reduce-motion', 'true');
root.style.setProperty('--animation-duration', `${MOTION_DEFAULTS.reducedDuration}ms`);
root.style.setProperty('--transition-duration', `${MOTION_DEFAULTS.reducedDuration}ms`);
} else {
root.removeAttribute('data-reduce-motion');
root.style.setProperty('--animation-duration', `${MOTION_DEFAULTS.animationDuration}ms`);
root.style.setProperty('--transition-duration', `${MOTION_DEFAULTS.transitionDuration}ms`);
}
}
// ============================================================================
// High Contrast Transformations
// ============================================================================
/**
* Color role classification for contrast adjustments
*/
type ColorRole = 'background' | 'foreground' | 'border' | 'primary' | 'semantic' | 'other';
/**
* Get the role of a color based on its key
*/
function getColorRole(colorKey: keyof ThemeColors): ColorRole {
const backgrounds = ['background', 'surface', 'surfaceHover', 'surfaceElevated', 'muted', 'input'];
const foregrounds = ['foreground', 'primaryForeground', 'secondaryForeground', 'mutedForeground'];
const borders = ['border', 'borderStrong', 'ring'];
const primaries = ['primary', 'secondary'];
const semantics = ['error', 'success', 'warning'];
if (backgrounds.includes(colorKey)) return 'background';
if (foregrounds.includes(colorKey)) return 'foreground';
if (borders.includes(colorKey)) return 'border';
if (primaries.includes(colorKey)) return 'primary';
if (semantics.includes(colorKey)) return 'semantic';
return 'other';
}
/**
* Apply high contrast transformation to a single color
*/
function applyHighContrastToColor(
hsl: HSLValue,
colorKey: keyof ThemeColors,
mode: EffectiveMode
): HSLValue {
const { h, s, l } = parseHSL(hsl);
const role = getColorRole(colorKey);
let newL = l;
let newS = s;
if (mode === 'light') {
const config = HIGH_CONTRAST_CONFIG.light;
switch (role) {
case 'background':
newL = Math.max(l, config.backgroundLightnessMin);
break;
case 'foreground':
newL = Math.min(l, config.foregroundLightnessMax);
break;
case 'border':
newL = Math.max(0, l - config.borderDarken);
break;
case 'primary':
case 'semantic':
newS = Math.max(s, config.primarySaturationMin);
newL = Math.min(l, 45);
break;
}
} else {
const config = HIGH_CONTRAST_CONFIG.dark;
switch (role) {
case 'background':
newL = Math.min(l, config.backgroundLightnessMax);
break;
case 'foreground':
newL = Math.max(l, config.foregroundLightnessMin);
break;
case 'border':
newL = Math.min(100, l + config.borderLighten);
break;
case 'primary':
case 'semantic':
newS = Math.max(s, config.primarySaturationMin);
newL = Math.max(l, 55);
break;
}
}
return createHSL(h, newS, newL);
}
/**
* Apply high contrast transformations to all theme colors
*/
export function applyHighContrast(colors: ThemeColors, mode: EffectiveMode): ThemeColors {
const result = { ...colors };
for (const key of Object.keys(colors) as (keyof ThemeColors)[]) {
result[key] = applyHighContrastToColor(colors[key], key, mode);
}
return result;
}
// ============================================================================
// Colorblind Transformations
// ============================================================================
/**
* Shift hue within a range
*/
function shiftHueInRange(
h: number,
rangeStart: number,
rangeEnd: number,
shift: number
): number {
if (h >= rangeStart && h <= rangeEnd) {
return (h + shift) % 360;
}
return h;
}
/**
* Apply colorblind transformation to a single color
*/
function applyColorblindToColor(hsl: HSLValue, mode: ColorblindMode): HSLValue {
if (mode === 'none') return hsl;
const { h, s, l } = parseHSL(hsl);
if (mode === 'monochrome') {
// Full grayscale - remove all saturation
return createHSL(h, 0, l);
}
if (mode === 'deuteranopia') {
const config = COLORBLIND_TRANSFORMS.deuteranopia;
const newH = shiftHueInRange(h, config.hueRangeStart, config.hueRangeEnd, config.hueShift);
const newS = s * config.saturationScale;
return createHSL(newH, newS, l);
}
if (mode === 'protanopia') {
const config = COLORBLIND_TRANSFORMS.protanopia;
let newH = shiftHueInRange(h, config.hueRangeStart, config.hueRangeEnd, config.hueShift);
// Also handle wrap-around reds (330-360)
newH = shiftHueInRange(newH, config.hueRangeStart2, config.hueRangeEnd2, config.hueShift);
const newS = s * config.saturationScale;
return createHSL(newH, newS, l);
}
return hsl;
}
/**
* Apply colorblind transformations to all theme colors
*/
export function applyColorblindTransform(
colors: ThemeColors,
mode: ColorblindMode
): ThemeColors {
if (mode === 'none') return colors;
const result = { ...colors };
for (const key of Object.keys(colors) as (keyof ThemeColors)[]) {
result[key] = applyColorblindToColor(colors[key], mode);
}
return result;
}
// ============================================================================
// Combined A11y Application
// ============================================================================
/**
* Apply all A11y transformations to theme colors
*/
export function applyA11yTransformations(
colors: ThemeColors,
mode: EffectiveMode,
a11ySettings: A11ySettings
): ThemeColors {
let result = { ...colors };
// Apply high contrast first (if enabled)
if (a11ySettings.contrast === 'high') {
result = applyHighContrast(result, mode);
}
// Apply colorblind transformation
if (a11ySettings.colorblind !== 'none') {
result = applyColorblindTransform(result, a11ySettings.colorblind);
}
return result;
}
/**
* Apply A11y data attributes to document
*/
export function applyA11yAttributes(a11ySettings: A11ySettings): void {
if (!isBrowser()) return;
const root = document.documentElement;
// Contrast level
if (a11ySettings.contrast === 'high') {
root.setAttribute('data-contrast', 'high');
} else {
root.removeAttribute('data-contrast');
}
// Colorblind mode
if (a11ySettings.colorblind !== 'none') {
root.setAttribute('data-colorblind', a11ySettings.colorblind);
} else {
root.removeAttribute('data-colorblind');
}
// Motion settings
applyMotionSettings(a11ySettings.reduceMotion);
}
// ============================================================================
// Storage
// ============================================================================
/**
* Load A11y settings from localStorage
*/
export function loadA11yFromStorage(storageKey: string): Partial<A11ySettings> | null {
if (!isBrowser()) return null;
try {
const stored = localStorage.getItem(storageKey);
if (stored) {
return JSON.parse(stored);
}
} catch (e) {
console.warn('Failed to load A11y settings from storage:', e);
}
return null;
}
/**
* Save A11y settings to localStorage
*/
export function saveA11yToStorage(storageKey: string, settings: A11ySettings): void {
if (!isBrowser()) return;
try {
localStorage.setItem(storageKey, JSON.stringify(settings));
} catch (e) {
console.warn('Failed to save A11y settings to storage:', e);
}
}