mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-22 07:26:42 +02:00
✨ 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:
parent
6cc9f70a4a
commit
02c82c7547
33 changed files with 1474 additions and 143 deletions
114
packages/shared-theme-ui/src/components/A11yQuickToggles.svelte
Normal file
114
packages/shared-theme-ui/src/components/A11yQuickToggles.svelte
Normal 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>
|
||||
188
packages/shared-theme-ui/src/components/A11ySettings.svelte
Normal file
188
packages/shared-theme-ui/src/components/A11ySettings.svelte
Normal 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>
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
132
packages/shared-theme/src/a11y-constants.ts
Normal file
132
packages/shared-theme/src/a11y-constants.ts
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
import type { A11ySettings, ContrastLevel, ColorblindMode } from './types';
|
||||
|
||||
/**
|
||||
* localStorage key suffix for A11y settings
|
||||
*/
|
||||
export const A11Y_STORAGE_KEY_SUFFIX = '-a11y';
|
||||
|
||||
/**
|
||||
* Default A11y settings
|
||||
*/
|
||||
export const DEFAULT_A11Y_SETTINGS: A11ySettings = {
|
||||
contrast: 'normal',
|
||||
colorblind: 'none',
|
||||
reduceMotion: false,
|
||||
};
|
||||
|
||||
/**
|
||||
* Colorblind mode options for UI
|
||||
*/
|
||||
export const COLORBLIND_OPTIONS: readonly {
|
||||
value: ColorblindMode;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: 'none',
|
||||
label: 'Keine',
|
||||
description: 'Standardfarben',
|
||||
},
|
||||
{
|
||||
value: 'deuteranopia',
|
||||
label: 'Deuteranopie',
|
||||
description: 'Rot-Grün-Schwäche (häufigste Form)',
|
||||
},
|
||||
{
|
||||
value: 'protanopia',
|
||||
label: 'Protanopie',
|
||||
description: 'Rot-Blindheit',
|
||||
},
|
||||
{
|
||||
value: 'monochrome',
|
||||
label: 'Monochrom',
|
||||
description: 'Graustufen',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* Contrast level options for UI
|
||||
*/
|
||||
export const CONTRAST_OPTIONS: readonly {
|
||||
value: ContrastLevel;
|
||||
label: string;
|
||||
description: string;
|
||||
}[] = [
|
||||
{
|
||||
value: 'normal',
|
||||
label: 'Normal',
|
||||
description: 'Standard-Kontrast (WCAG AA)',
|
||||
},
|
||||
{
|
||||
value: 'high',
|
||||
label: 'Hoch',
|
||||
description: 'Erhöhter Kontrast (WCAG AAA)',
|
||||
},
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* High contrast transformation config
|
||||
* Adjusts lightness values to achieve WCAG AAA (7:1) contrast ratios
|
||||
*/
|
||||
export const HIGH_CONTRAST_CONFIG = {
|
||||
light: {
|
||||
/** Minimum lightness for backgrounds (push towards white) */
|
||||
backgroundLightnessMin: 95,
|
||||
/** Maximum lightness for foregrounds (push towards black) */
|
||||
foregroundLightnessMax: 15,
|
||||
/** How much to darken borders */
|
||||
borderDarken: 15,
|
||||
/** Minimum saturation boost for primary colors */
|
||||
primarySaturationMin: 70,
|
||||
},
|
||||
dark: {
|
||||
/** Maximum lightness for backgrounds (push towards black) */
|
||||
backgroundLightnessMax: 8,
|
||||
/** Minimum lightness for foregrounds (push towards white) */
|
||||
foregroundLightnessMin: 90,
|
||||
/** How much to lighten borders */
|
||||
borderLighten: 15,
|
||||
/** Minimum saturation boost for primary colors */
|
||||
primarySaturationMin: 70,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Colorblind transformation configs
|
||||
* Hue shifts to make colors more distinguishable for each condition
|
||||
*/
|
||||
export const COLORBLIND_TRANSFORMS = {
|
||||
deuteranopia: {
|
||||
/** Shift problematic green hues towards blue */
|
||||
hueRangeStart: 80,
|
||||
hueRangeEnd: 160,
|
||||
hueShift: 60,
|
||||
saturationScale: 0.85,
|
||||
},
|
||||
protanopia: {
|
||||
/** Shift problematic red hues towards yellow */
|
||||
hueRangeStart: 0,
|
||||
hueRangeEnd: 30,
|
||||
hueShift: 30,
|
||||
/** Also handle wrap-around (330-360) */
|
||||
hueRangeStart2: 330,
|
||||
hueRangeEnd2: 360,
|
||||
saturationScale: 0.85,
|
||||
},
|
||||
monochrome: {
|
||||
/** Remove all saturation */
|
||||
saturationScale: 0,
|
||||
},
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Default animation/transition durations
|
||||
*/
|
||||
export const MOTION_DEFAULTS = {
|
||||
/** Default animation duration in ms */
|
||||
animationDuration: 300,
|
||||
/** Default transition duration in ms */
|
||||
transitionDuration: 200,
|
||||
/** Reduced (0) for prefers-reduced-motion */
|
||||
reducedDuration: 0,
|
||||
} as const;
|
||||
192
packages/shared-theme/src/a11y-store.svelte.ts
Normal file
192
packages/shared-theme/src/a11y-store.svelte.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
312
packages/shared-theme/src/a11y-utils.ts
Normal file
312
packages/shared-theme/src/a11y-utils.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,12 @@ export type {
|
|||
AppThemeConfig,
|
||||
ThemeStore,
|
||||
HSLValue,
|
||||
// A11y Types
|
||||
ContrastLevel,
|
||||
ColorblindMode,
|
||||
A11ySettings,
|
||||
A11yStore,
|
||||
A11yStoreConfig,
|
||||
} from './types';
|
||||
|
||||
// Constants
|
||||
|
|
@ -21,9 +27,23 @@ export {
|
|||
STORAGE_KEY_SUFFIX,
|
||||
} from './constants';
|
||||
|
||||
// A11y Constants
|
||||
export {
|
||||
A11Y_STORAGE_KEY_SUFFIX,
|
||||
DEFAULT_A11Y_SETTINGS,
|
||||
COLORBLIND_OPTIONS,
|
||||
CONTRAST_OPTIONS,
|
||||
HIGH_CONTRAST_CONFIG,
|
||||
COLORBLIND_TRANSFORMS,
|
||||
MOTION_DEFAULTS,
|
||||
} from './a11y-constants';
|
||||
|
||||
// Store
|
||||
export { createThemeStore, APP_THEME_CONFIGS } from './store.svelte';
|
||||
|
||||
// A11y Store
|
||||
export { createA11yStore } from './a11y-store.svelte';
|
||||
|
||||
// Utils
|
||||
export {
|
||||
isBrowser,
|
||||
|
|
@ -41,3 +61,16 @@ export {
|
|||
getContrastColor,
|
||||
generateThemeCSS,
|
||||
} from './utils';
|
||||
|
||||
// A11y Utils
|
||||
export {
|
||||
getSystemReducedMotion,
|
||||
createReducedMotionListener,
|
||||
applyMotionSettings,
|
||||
applyHighContrast,
|
||||
applyColorblindTransform,
|
||||
applyA11yTransformations,
|
||||
applyA11yAttributes,
|
||||
loadA11yFromStorage,
|
||||
saveA11yToStorage,
|
||||
} from './a11y-utils';
|
||||
|
|
|
|||
|
|
@ -134,3 +134,72 @@ export interface ThemeStore {
|
|||
/** Initialize theme (call on mount) */
|
||||
initialize: () => () => void;
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Accessibility (A11y) Types
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Contrast level for accessibility
|
||||
* - 'normal': Default contrast (WCAG AA 4.5:1 minimum)
|
||||
* - 'high': Enhanced contrast (WCAG AAA 7:1 minimum)
|
||||
*/
|
||||
export type ContrastLevel = 'normal' | 'high';
|
||||
|
||||
/**
|
||||
* Colorblind mode simulation/adaptation
|
||||
* - 'none': No colorblind adaptation
|
||||
* - 'deuteranopia': Green-blind (most common, ~6% of males)
|
||||
* - 'protanopia': Red-blind (~1% of males)
|
||||
* - 'monochrome': Full grayscale (achromatopsia)
|
||||
*/
|
||||
export type ColorblindMode = 'none' | 'deuteranopia' | 'protanopia' | 'monochrome';
|
||||
|
||||
/**
|
||||
* A11y settings state
|
||||
*/
|
||||
export interface A11ySettings {
|
||||
/** Contrast level */
|
||||
contrast: ContrastLevel;
|
||||
/** Colorblind adaptation mode */
|
||||
colorblind: ColorblindMode;
|
||||
/** Reduce motion preference */
|
||||
reduceMotion: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* A11y store interface (separate from ThemeStore)
|
||||
*/
|
||||
export interface A11yStore {
|
||||
/** Current contrast level */
|
||||
readonly contrast: ContrastLevel;
|
||||
/** Current colorblind mode */
|
||||
readonly colorblind: ColorblindMode;
|
||||
/** Effective reduce motion (user setting OR system preference) */
|
||||
readonly reduceMotion: boolean;
|
||||
/** Whether user has explicitly set reduce motion (vs system default) */
|
||||
readonly reduceMotionExplicit: boolean;
|
||||
|
||||
/** Set contrast level */
|
||||
setContrast: (level: ContrastLevel) => void;
|
||||
/** Set colorblind mode */
|
||||
setColorblind: (mode: ColorblindMode) => void;
|
||||
/** Set reduce motion preference */
|
||||
setReduceMotion: (reduce: boolean) => void;
|
||||
/** Reset to system default for reduce motion */
|
||||
resetReduceMotion: () => void;
|
||||
/** Reset all A11y settings to defaults */
|
||||
resetAll: () => void;
|
||||
/** Initialize (call on mount) */
|
||||
initialize: () => () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A11y store configuration
|
||||
*/
|
||||
export interface A11yStoreConfig {
|
||||
/** App identifier for localStorage key */
|
||||
appId: string;
|
||||
/** Default settings override */
|
||||
defaults?: Partial<A11ySettings>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import type { ThemeColors, ThemeVariant, EffectiveMode, HSLValue } from './types';
|
||||
import type { ThemeColors, ThemeVariant, EffectiveMode, HSLValue, A11ySettings } from './types';
|
||||
import { THEME_DEFINITIONS, CSS_VAR_PREFIX } from './constants';
|
||||
import { applyA11yTransformations, applyA11yAttributes } from './a11y-utils';
|
||||
|
||||
/**
|
||||
* Check if code is running in browser
|
||||
|
|
@ -90,12 +91,20 @@ export function colorsToCssVars(colors: ThemeColors): Record<string, string> {
|
|||
export function applyThemeToDocument(
|
||||
variant: ThemeVariant,
|
||||
effectiveMode: EffectiveMode,
|
||||
primaryOverride?: { light: HSLValue; dark: HSLValue }
|
||||
primaryOverride?: { light: HSLValue; dark: HSLValue },
|
||||
a11ySettings?: A11ySettings
|
||||
): void {
|
||||
if (!isBrowser()) return;
|
||||
|
||||
const root = document.documentElement;
|
||||
const colors = getThemeColors(variant, effectiveMode, primaryOverride);
|
||||
let colors = getThemeColors(variant, effectiveMode, primaryOverride);
|
||||
|
||||
// Apply A11y transformations if provided
|
||||
if (a11ySettings) {
|
||||
colors = applyA11yTransformations(colors, effectiveMode, a11ySettings);
|
||||
applyA11yAttributes(a11ySettings);
|
||||
}
|
||||
|
||||
const cssVars = colorsToCssVars(colors);
|
||||
|
||||
// Set CSS variables
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@
|
|||
onToggle?: (open: boolean) => void;
|
||||
/** Optional header content (e.g., mode selector) */
|
||||
header?: Snippet;
|
||||
/** Optional footer content (e.g., a11y toggles) */
|
||||
footer?: Snippet;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -21,6 +23,7 @@
|
|||
isOpen = false,
|
||||
onToggle,
|
||||
header,
|
||||
footer,
|
||||
}: Props = $props();
|
||||
|
||||
let internalOpen = $state(false);
|
||||
|
|
@ -248,6 +251,13 @@
|
|||
{/if}
|
||||
{/if}
|
||||
{/each}
|
||||
|
||||
<!-- Optional footer (e.g., a11y toggles) -->
|
||||
{#if footer}
|
||||
<div class="dropdown-footer">
|
||||
{@render footer()}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -487,4 +497,34 @@
|
|||
flex: 1;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
/* Footer for custom content (e.g., a11y toggles) */
|
||||
.dropdown-footer {
|
||||
animation: fanIn 0.15s ease-out forwards;
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
margin-top: 0.25rem;
|
||||
padding-top: 0.5rem;
|
||||
border-top: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .dropdown-footer {
|
||||
border-top-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
|
||||
.fan-up .dropdown-footer {
|
||||
transform: translateY(-10px);
|
||||
margin-top: 0;
|
||||
margin-bottom: 0.25rem;
|
||||
padding-top: 0;
|
||||
padding-bottom: 0.5rem;
|
||||
border-top: none;
|
||||
border-bottom: 1px solid rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
:global(.dark) .fan-up .dropdown-footer {
|
||||
border-bottom-color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -120,6 +120,17 @@
|
|||
allAppsHref?: string;
|
||||
/** All Apps label (default: "Alle Apps") */
|
||||
allAppsLabel?: string;
|
||||
// A11y Settings
|
||||
/** A11y contrast level */
|
||||
a11yContrast?: 'normal' | 'high';
|
||||
/** Called when a11y contrast changes */
|
||||
onA11yContrastChange?: (contrast: 'normal' | 'high') => void;
|
||||
/** A11y reduce motion setting */
|
||||
a11yReduceMotion?: boolean;
|
||||
/** Called when a11y reduce motion changes */
|
||||
onA11yReduceMotionChange?: (reduce: boolean) => void;
|
||||
/** Show a11y quick toggles in theme dropdown */
|
||||
showA11yQuickToggles?: boolean;
|
||||
}
|
||||
|
||||
let {
|
||||
|
|
@ -156,6 +167,11 @@
|
|||
loginHref,
|
||||
allAppsHref,
|
||||
allAppsLabel = 'Alle Apps',
|
||||
a11yContrast = 'normal',
|
||||
onA11yContrastChange,
|
||||
a11yReduceMotion = false,
|
||||
onA11yReduceMotionChange,
|
||||
showA11yQuickToggles = false,
|
||||
}: Props = $props();
|
||||
|
||||
// Type guards for elements
|
||||
|
|
@ -241,6 +257,10 @@
|
|||
// Icon SVG paths
|
||||
const icons: Record<string, string> = {
|
||||
mic: 'M19 11a7 7 0 01-7 7m0 0a7 7 0 01-7-7m7 7v4m0 0H8m4 0h4m-4-8a3 3 0 01-3-3V5a3 3 0 116 0v6a3 3 0 01-3 3z',
|
||||
calendar:
|
||||
'M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z',
|
||||
folder:
|
||||
'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z',
|
||||
archive: 'M5 8h14M5 8a2 2 0 110-4h14a2 2 0 110 4M5 8v10a2 2 0 002 2h10a2 2 0 002-2V8m-9 4h4',
|
||||
upload: 'M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12',
|
||||
music:
|
||||
|
|
@ -447,6 +467,44 @@
|
|||
</button>
|
||||
</div>
|
||||
{/snippet}
|
||||
{#snippet footer()}
|
||||
{#if showA11yQuickToggles}
|
||||
<div class="a11y-quick-toggles glass-pill">
|
||||
<!-- Contrast Toggle -->
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => onA11yContrastChange?.(a11yContrast === 'high' ? 'normal' : 'high')}
|
||||
class="a11y-btn"
|
||||
class:active={a11yContrast === 'high'}
|
||||
title="Hoher Kontrast"
|
||||
aria-pressed={a11yContrast === '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={() => onA11yReduceMotionChange?.(!a11yReduceMotion)}
|
||||
class="a11y-btn"
|
||||
class:active={a11yReduceMotion}
|
||||
title="Animationen reduzieren"
|
||||
aria-pressed={a11yReduceMotion}
|
||||
>
|
||||
<svg class="a11y-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
{#if a11yReduceMotion}
|
||||
<rect x="6" y="4" width="4" height="16" rx="1" />
|
||||
<rect x="14" y="4" width="4" height="16" rx="1" />
|
||||
{:else}
|
||||
<polygon points="5 3 19 12 5 21 5 3" />
|
||||
{/if}
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</PillDropdown>
|
||||
{/if}
|
||||
|
||||
|
|
@ -1167,4 +1225,71 @@
|
|||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
/* A11y quick toggles in dropdown footer */
|
||||
:global(.a11y-quick-toggles) {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
gap: 0.25rem !important;
|
||||
padding: 0.25rem !important;
|
||||
border-radius: 9999px !important;
|
||||
background: rgba(245, 245, 245, 0.95) !important;
|
||||
border: 1px solid rgba(0, 0, 0, 0.1) !important;
|
||||
color: #374151 !important;
|
||||
}
|
||||
|
||||
:global(.dark .a11y-quick-toggles) {
|
||||
background: rgba(40, 40, 40, 0.95) !important;
|
||||
border: 1px solid rgba(255, 255, 255, 0.15) !important;
|
||||
color: #f3f4f6 !important;
|
||||
}
|
||||
|
||||
:global(.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;
|
||||
}
|
||||
|
||||
:global(.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;
|
||||
}
|
||||
|
||||
:global(.a11y-btn.active) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 20%,
|
||||
white 80%
|
||||
);
|
||||
color: var(--pill-primary-color, var(--color-primary-500, #3b82f6));
|
||||
}
|
||||
|
||||
:global(.dark .a11y-btn.active) {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--pill-primary-color, var(--color-primary-500, #3b82f6)) 30%,
|
||||
transparent 70%
|
||||
);
|
||||
}
|
||||
|
||||
:global(.a11y-icon) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue