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,114 @@
<script lang="ts">
import type { A11yStore } from '@manacore/shared-theme';
interface Props {
/** A11y store instance */
store: A11yStore;
/** Contrast toggle title */
contrastTitle?: string;
/** Motion toggle title */
motionTitle?: string;
}
let {
store,
contrastTitle = 'Hoher Kontrast',
motionTitle = 'Animationen reduzieren',
}: Props = $props();
</script>
<div class="a11y-quick-toggles">
<!-- Contrast Toggle -->
<button
type="button"
onclick={() => store.setContrast(store.contrast === 'high' ? 'normal' : 'high')}
class="a11y-btn"
class:active={store.contrast === 'high'}
title={contrastTitle}
aria-pressed={store.contrast === 'high'}
>
<svg class="a11y-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<path d="M12 2v20M12 2a10 10 0 0 1 0 20" fill="currentColor" />
</svg>
</button>
<!-- Reduce Motion Toggle -->
<button
type="button"
onclick={() => store.setReduceMotion(!store.reduceMotion)}
class="a11y-btn"
class:active={store.reduceMotion}
title={motionTitle}
aria-pressed={store.reduceMotion}
>
<svg class="a11y-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
{#if store.reduceMotion}
<!-- Pause icon when motion is reduced -->
<rect x="6" y="4" width="4" height="16" rx="1" />
<rect x="14" y="4" width="4" height="16" rx="1" />
{:else}
<!-- Play/motion icon -->
<polygon points="5 3 19 12 5 21 5 3" />
{/if}
</svg>
</button>
</div>
<style>
.a11y-quick-toggles {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.25rem;
border-radius: 9999px;
background: rgba(245, 245, 245, 0.95);
border: 1px solid rgba(0, 0, 0, 0.1);
}
:global(.dark) .a11y-quick-toggles {
background: rgba(40, 40, 40, 0.95);
border: 1px solid rgba(255, 255, 255, 0.15);
}
.a11y-btn {
display: flex;
align-items: center;
justify-content: center;
padding: 0.375rem;
border: none;
background: transparent;
border-radius: 9999px;
cursor: pointer;
color: #6b7280;
transition: all 0.15s;
}
:global(.dark) .a11y-btn {
color: #9ca3af;
}
.a11y-btn:hover:not(.active) {
background: rgba(0, 0, 0, 0.05);
color: #374151;
}
:global(.dark) .a11y-btn:hover:not(.active) {
background: rgba(255, 255, 255, 0.1);
color: #f3f4f6;
}
.a11y-btn.active {
background: hsl(var(--color-primary) / 0.2);
color: hsl(var(--color-primary));
}
:global(.dark) .a11y-btn.active {
background: hsl(var(--color-primary) / 0.3);
}
.a11y-icon {
width: 1rem;
height: 1rem;
}
</style>

View file

@ -0,0 +1,188 @@
<script lang="ts">
import type { A11yStore, ContrastLevel, ColorblindMode } from '@manacore/shared-theme';
import { COLORBLIND_OPTIONS, CONTRAST_OPTIONS } from '@manacore/shared-theme';
import type { A11yTranslations } from '../types';
import { defaultA11yTranslations } from '../types';
interface Props {
/** A11y store instance */
store: A11yStore;
/** Custom translations */
translations?: Partial<A11yTranslations>;
/** Show reset button */
showReset?: boolean;
}
let { store, translations = {}, showReset = true }: Props = $props();
// Merge translations with defaults
const t = $derived({ ...defaultA11yTranslations, ...translations });
// Colorblind mode labels mapped to translations
const colorblindLabels: Record<ColorblindMode, string> = $derived({
none: t.colorblindNone,
deuteranopia: t.colorblindDeuteranopia,
protanopia: t.colorblindProtanopia,
monochrome: t.colorblindMonochrome,
});
</script>
<div class="a11y-settings space-y-6">
<!-- Contrast Setting -->
<div class="setting-group">
<span class="setting-label">{t.contrastLabel}</span>
<div class="inline-flex rounded-lg bg-muted p-1" role="group" aria-label={t.contrastLabel}>
{#each CONTRAST_OPTIONS as option}
<button
type="button"
onclick={() => store.setContrast(option.value)}
class="flex items-center gap-2 px-4 py-2 rounded-md text-sm font-medium transition-colors
{store.contrast === option.value
? 'bg-surface text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'}"
title={option.description}
>
{option.value === 'normal' ? t.contrastNormal : t.contrastHigh}
</button>
{/each}
</div>
</div>
<!-- Colorblind Mode -->
<div class="setting-group">
<label for="colorblind-select" class="setting-label">{t.colorblindLabel}</label>
<select
id="colorblind-select"
class="select-input"
value={store.colorblind}
onchange={(e) => store.setColorblind(e.currentTarget.value as ColorblindMode)}
>
{#each COLORBLIND_OPTIONS as option}
<option value={option.value}>
{colorblindLabels[option.value]}
</option>
{/each}
</select>
</div>
<!-- Reduce Motion -->
<div class="setting-group">
<div class="flex items-center justify-between">
<div>
<label for="reduce-motion" class="setting-label mb-0">{t.reduceMotionLabel}</label>
<p class="text-sm text-muted-foreground">{t.reduceMotionDescription}</p>
</div>
<div class="flex items-center gap-2">
{#if store.reduceMotionExplicit}
<button
type="button"
onclick={() => store.resetReduceMotion()}
class="text-xs text-muted-foreground hover:text-foreground underline"
>
{t.systemDefault}
</button>
{/if}
<button
id="reduce-motion"
type="button"
role="switch"
aria-checked={store.reduceMotion}
aria-label={t.reduceMotionLabel}
onclick={() => store.setReduceMotion(!store.reduceMotion)}
class="toggle-switch"
class:active={store.reduceMotion}
>
<span class="toggle-thumb" class:active={store.reduceMotion}></span>
</button>
</div>
</div>
</div>
<!-- Reset Button -->
{#if showReset}
<div class="pt-2 border-t border-border">
<button
type="button"
onclick={() => store.resetAll()}
class="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
{t.reset}
</button>
</div>
{/if}
</div>
<style>
.a11y-settings {
width: 100%;
}
.setting-group {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.setting-label {
display: block;
font-size: 0.875rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
margin-bottom: 0.25rem;
}
.select-input {
width: 100%;
max-width: 20rem;
padding: 0.5rem 0.75rem;
font-size: 0.875rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: hsl(var(--color-input));
color: hsl(var(--color-foreground));
cursor: pointer;
transition: border-color 0.2s;
}
.select-input:hover {
border-color: hsl(var(--color-border-strong));
}
.select-input:focus {
outline: none;
border-color: hsl(var(--color-ring));
box-shadow: 0 0 0 2px hsl(var(--color-ring) / 0.2);
}
.toggle-switch {
position: relative;
width: 2.75rem;
height: 1.5rem;
border-radius: 9999px;
background: hsl(var(--color-muted));
border: none;
cursor: pointer;
transition: background-color 0.2s;
flex-shrink: 0;
}
.toggle-switch.active {
background: hsl(var(--color-primary));
}
.toggle-thumb {
position: absolute;
top: 0.125rem;
left: 0.125rem;
width: 1.25rem;
height: 1.25rem;
border-radius: 9999px;
background: white;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2);
transition: transform 0.2s;
}
.toggle-thumb.active {
transform: translateX(1.25rem);
}
</style>

View file

@ -3,14 +3,24 @@ export { default as ThemeToggle } from './ThemeToggle.svelte';
export { default as ThemeSelector } from './ThemeSelector.svelte';
export { default as ThemeModeSelector } from './ThemeModeSelector.svelte';
// New Components
// Theme Components
export { default as ThemeColorPreview } from './components/ThemeColorPreview.svelte';
export { default as ThemeCard } from './components/ThemeCard.svelte';
export { default as ThemeGrid } from './components/ThemeGrid.svelte';
// A11y Components
export { default as A11ySettings } from './components/A11ySettings.svelte';
export { default as A11yQuickToggles } from './components/A11yQuickToggles.svelte';
// Pages
export { default as ThemePage } from './pages/ThemePage.svelte';
// Types
export type { ThemeStatus, ThemeCardData, ThemePageProps, ThemePageTranslations } from './types';
export { defaultTranslations } from './types';
export type {
ThemeStatus,
ThemeCardData,
ThemePageProps,
ThemePageTranslations,
A11yTranslations,
} from './types';
export { defaultTranslations, defaultA11yTranslations } from './types';

View file

@ -1,9 +1,10 @@
<script lang="ts">
import type { ThemeVariant, ThemeMode } from '@manacore/shared-theme';
import type { ThemeVariant, ThemeMode, A11yStore } from '@manacore/shared-theme';
import { ArrowLeft, Sun, Moon, Desktop } from '@manacore/shared-icons';
import type { ThemeCardData, ThemePageTranslations } from '../types';
import { defaultTranslations } from '../types';
import type { ThemeCardData, ThemePageTranslations, A11yTranslations } from '../types';
import { defaultTranslations, defaultA11yTranslations } from '../types';
import ThemeGrid from '../components/ThemeGrid.svelte';
import A11ySettings from '../components/A11ySettings.svelte';
interface Props {
// Theme Store Integration
@ -30,6 +31,11 @@
// Translations
translations?: Partial<ThemePageTranslations>;
// A11y Settings
a11yStore?: A11yStore;
showA11ySettings?: boolean;
a11yTranslations?: Partial<A11yTranslations>;
}
let {
@ -46,10 +52,14 @@
showLockedThemes = true,
onUnlockTheme,
translations = {},
a11yStore,
showA11ySettings = false,
a11yTranslations = {},
}: Props = $props();
// Merge translations with defaults
const t = $derived({ ...defaultTranslations, ...translations });
const a11yT = $derived({ ...defaultA11yTranslations, ...a11yTranslations });
const modes: { mode: ThemeMode; icon: typeof Sun; label: string }[] = $derived([
{ mode: 'light', icon: Sun, label: t.lightMode },
@ -121,5 +131,15 @@
{translations}
/>
</section>
<!-- A11y Settings -->
{#if showA11ySettings && a11yStore}
<section class="mt-8 pt-8 border-t border-border">
<h2 class="text-sm font-medium text-muted-foreground mb-4">
{a11yT.a11yTitle}
</h2>
<A11ySettings store={a11yStore} translations={a11yTranslations} />
</section>
{/if}
</div>
</div>

View file

@ -85,3 +85,58 @@ export const defaultTranslations: ThemePageTranslations = {
lightPreview: 'Hell',
darkPreview: 'Dunkel',
};
// ============================================================================
// A11y (Accessibility) Types
// ============================================================================
/**
* Translations for A11y settings
*/
export interface A11yTranslations {
/** Section title */
a11yTitle: string;
/** Contrast setting label */
contrastLabel: string;
/** Normal contrast option */
contrastNormal: string;
/** High contrast option */
contrastHigh: string;
/** Colorblind setting label */
colorblindLabel: string;
/** No colorblind adaptation */
colorblindNone: string;
/** Deuteranopia option */
colorblindDeuteranopia: string;
/** Protanopia option */
colorblindProtanopia: string;
/** Monochrome option */
colorblindMonochrome: string;
/** Reduce motion label */
reduceMotionLabel: string;
/** Reduce motion description */
reduceMotionDescription: string;
/** System default label */
systemDefault: string;
/** Reset button */
reset: string;
}
/**
* Default German A11y translations
*/
export const defaultA11yTranslations: A11yTranslations = {
a11yTitle: 'Barrierefreiheit',
contrastLabel: 'Kontrast',
contrastNormal: 'Normal',
contrastHigh: 'Hoch',
colorblindLabel: 'Farbsehschwäche',
colorblindNone: 'Keine',
colorblindDeuteranopia: 'Rot-Grün (Deuteranopie)',
colorblindProtanopia: 'Rot-Blindheit (Protanopie)',
colorblindMonochrome: 'Monochrom',
reduceMotionLabel: 'Animationen reduzieren',
reduceMotionDescription: 'Deaktiviert Animationen und Übergänge',
systemDefault: 'System',
reset: 'Zurücksetzen',
};