accessibility-sidebar
Version:
A comprehensive, lightweight accessibility widget for web and mobile apps with WCAG 2.1 AA compliance
461 lines (422 loc) • 13.6 kB
JSX
import React, { useState, useEffect } from 'react';
import {
View,
Text,
TouchableOpacity,
Animated,
PanGestureHandler,
GestureHandlerRootView,
Dimensions,
Alert,
StyleSheet,
StatusBar,
Platform
} from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import AsyncStorage from '@react-native-async-storage/async-storage';
import * as Speech from 'expo-speech';
import Icon from 'react-native-vector-icons/FontAwesome5';
const { width: SCREEN_WIDTH, height: SCREEN_HEIGHT } = Dimensions.get('window');
const AccessibilitySidebar = ({
children,
targetContent = null,
onFontSizeChange = null,
onContrastChange = null,
onLineHeightChange = null
}) => {
const insets = useSafeAreaInsets();
// State management
const [isPanelOpen, setIsPanelOpen] = useState(false);
const [fontSize, setFontSize] = useState(0); // 0: normal, 1: larger, 2: largest
const [highContrast, setHighContrast] = useState(false);
const [lineHeight, setLineHeight] = useState(0); // 0: normal, 1: larger, 2: largest
const [isReading, setIsReading] = useState(false);
// Animation values
const panelWidth = useState(new Animated.Value(56))[0];
const translateX = useState(new Animated.Value(16))[0];
const translateY = useState(new Animated.Value(100))[0];
// Device info
const isTablet = SCREEN_WIDTH >= 768;
const isIOS = Platform.OS === 'ios';
// Load saved settings
useEffect(() => {
loadSettings();
}, []);
// Save settings when they change
useEffect(() => {
saveSettings();
}, [fontSize, highContrast, lineHeight]);
const loadSettings = async () => {
try {
const settings = await AsyncStorage.getItem('@accessibility_settings');
if (settings) {
const { fontSize: savedFontSize, highContrast: savedContrast, lineHeight: savedLineHeight } = JSON.parse(settings);
setFontSize(savedFontSize || 0);
setHighContrast(savedContrast || false);
setLineHeight(savedLineHeight || 0);
}
} catch (error) {
console.warn('Failed to load accessibility settings:', error);
}
};
const saveSettings = async () => {
try {
const settings = {
fontSize,
highContrast,
lineHeight
};
await AsyncStorage.setItem('@accessibility_settings', JSON.stringify(settings));
} catch (error) {
console.warn('Failed to save accessibility settings:', error);
}
};
// Toggle panel visibility
const togglePanel = () => {
const newState = !isPanelOpen;
setIsPanelOpen(newState);
Animated.timing(panelWidth, {
toValue: newState ? 280 : 56,
duration: 300,
useNativeDriver: false,
}).start();
};
// Handle font size changes
const handleFontSizeChange = () => {
const newSize = (fontSize + 1) % 3;
setFontSize(newSize);
if (onFontSizeChange) {
onFontSizeChange(newSize);
}
};
// Handle contrast toggle
const handleContrastToggle = () => {
const newState = !highContrast;
setHighContrast(newState);
if (onContrastChange) {
onContrastChange(newState);
}
};
// Handle line height changes
const handleLineHeightChange = () => {
const newHeight = (lineHeight + 1) % 3;
setLineHeight(newHeight);
if (onLineHeightChange) {
onLineHeightChange(newHeight);
}
};
// Handle text-to-speech
const handleReadAloud = async () => {
if (isReading) {
Speech.stop();
setIsReading(false);
} else {
try {
// Get text content to read
const textToRead = targetContent || "Conținutul nu este disponibil pentru citire.";
setIsReading(true);
// Configure speech options
const speechOptions = {
language: 'ro-RO',
pitch: 1.0,
rate: 0.8,
onStart: () => setIsReading(true),
onDone: () => setIsReading(false),
onStopped: () => setIsReading(false),
onError: () => {
setIsReading(false);
Alert.alert('Eroare', 'Nu s-a putut începe citirea cu voce tare.');
}
};
await Speech.speak(textToRead, speechOptions);
} catch (error) {
setIsReading(false);
Alert.alert('Eroare', 'Citirea cu voce tare nu este disponibilă pe acest dispozitiv.');
}
}
};
// Reset all settings
const resetAllSettings = () => {
setFontSize(0);
setHighContrast(false);
setLineHeight(0);
if (isReading) {
Speech.stop();
setIsReading(false);
}
// Notify parent components
if (onFontSizeChange) onFontSizeChange(0);
if (onContrastChange) onContrastChange(false);
if (onLineHeightChange) onLineHeightChange(0);
};
// Get font size label
const getFontSizeLabel = () => {
switch (fontSize) {
case 0: return 'Normal';
case 1: return 'Mare';
case 2: return 'Foarte mare';
default: return 'Normal';
}
};
// Get line height label
const getLineHeightLabel = () => {
switch (lineHeight) {
case 0: return 'Normal';
case 1: return 'Mare';
case 2: return 'Foarte mare';
default: return 'Normal';
}
};
// Get dynamic styles based on settings
const getContainerStyle = () => ({
...styles.container,
backgroundColor: highContrast ? '#000' : '#fff',
borderColor: highContrast ? '#fff' : '#2196F3',
top: insets.top + 100,
});
const getTextStyle = (isActive = false) => ({
color: isActive
? (highContrast ? '#000' : '#2196F3')
: (highContrast ? '#fff' : '#333'),
fontWeight: isActive ? 'bold' : 'normal',
});
const getButtonStyle = (isActive = false) => ({
...styles.controlButton,
backgroundColor: isActive
? (highContrast ? '#fff' : '#e3f2fd')
: (highContrast ? '#333' : '#f5f5f5'),
});
return (
<GestureHandlerRootView style={styles.gestureContainer}>
<Animated.View
style={[
getContainerStyle(),
{
width: panelWidth,
transform: [
{ translateX: translateX },
{ translateY: translateY }
]
}
]}
>
{/* Header */}
<View style={styles.header}>
{isPanelOpen && (
<View style={styles.titleContainer}>
<Icon name="cog" size={20} color={highContrast ? '#fff' : '#2196F3'} />
<Text style={[styles.title, getTextStyle()]}>Accesibilitate</Text>
</View>
)}
<TouchableOpacity
onPress={togglePanel}
style={styles.toggleButton}
accessibilityLabel={isPanelOpen ? "Închide panoul de accesibilitate" : "Deschide panoul de accesibilitate"}
accessibilityRole="button"
>
<Icon
name={isPanelOpen ? "compress" : "cog"}
size={24}
color={highContrast ? '#fff' : '#2196F3'}
/>
</TouchableOpacity>
</View>
{/* Control Buttons - Only visible when panel is expanded */}
{isPanelOpen && (
<View style={styles.controlsContainer}>
{/* Font Size Control */}
<TouchableOpacity
style={getButtonStyle(fontSize > 0)}
onPress={handleFontSizeChange}
accessibilityLabel={`Mărime text: ${getFontSizeLabel()}`}
accessibilityRole="button"
>
<Icon name="font" size={20} color={getTextStyle(fontSize > 0).color} />
<View style={styles.buttonTextContainer}>
<Text style={[styles.buttonText, getTextStyle(fontSize > 0)]}>
Mărime text
</Text>
<Text style={[styles.buttonSubtext, getTextStyle(fontSize > 0)]}>
{getFontSizeLabel()}
</Text>
</View>
</TouchableOpacity>
{/* Contrast Control */}
<TouchableOpacity
style={getButtonStyle(highContrast)}
onPress={handleContrastToggle}
accessibilityLabel={`Contrast: ${highContrast ? 'ridicat' : 'normal'}`}
accessibilityRole="button"
>
<Icon
name={highContrast ? "sun" : "moon"}
size={20}
color={getTextStyle(highContrast).color}
/>
<View style={styles.buttonTextContainer}>
<Text style={[styles.buttonText, getTextStyle(highContrast)]}>
Contrast
</Text>
<Text style={[styles.buttonSubtext, getTextStyle(highContrast)]}>
{highContrast ? 'Ridicat' : 'Normal'}
</Text>
</View>
</TouchableOpacity>
{/* Line Height Control */}
<TouchableOpacity
style={getButtonStyle(lineHeight > 0)}
onPress={handleLineHeightChange}
accessibilityLabel={`Spațiu între rânduri: ${getLineHeightLabel()}`}
accessibilityRole="button"
>
<Icon
name="align-justify"
size={20}
color={getTextStyle(lineHeight > 0).color}
/>
<View style={styles.buttonTextContainer}>
<Text style={[styles.buttonText, getTextStyle(lineHeight > 0)]}>
Spațiu între rânduri
</Text>
<Text style={[styles.buttonSubtext, getTextStyle(lineHeight > 0)]}>
{getLineHeightLabel()}
</Text>
</View>
</TouchableOpacity>
{/* Text-to-Speech Control */}
<TouchableOpacity
style={getButtonStyle(isReading)}
onPress={handleReadAloud}
accessibilityLabel={isReading ? "Oprește citirea" : "Citește cu voce tare"}
accessibilityRole="button"
>
<Icon
name={isReading ? "volume-mute" : "volume-up"}
size={20}
color={getTextStyle(isReading).color}
/>
<View style={styles.buttonTextContainer}>
<Text style={[styles.buttonText, getTextStyle(isReading)]}>
{isReading ? "Oprește citirea" : "Citește cu voce tare"}
</Text>
<Text style={[styles.buttonSubtext, getTextStyle(isReading)]}>
{isReading ? 'Activ' : 'Inactiv'}
</Text>
</View>
</TouchableOpacity>
{/* Reset All Settings */}
<TouchableOpacity
style={[styles.controlButton, styles.resetButton, { backgroundColor: highContrast ? '#444' : '#f5f5f5' }]}
onPress={resetAllSettings}
accessibilityLabel="Resetează toate setările"
accessibilityRole="button"
>
<Icon name="undo" size={20} color={highContrast ? '#fff' : '#666'} />
<Text style={[styles.buttonText, { color: highContrast ? '#fff' : '#666' }]}>
Resetează setările
</Text>
</TouchableOpacity>
</View>
)}
{/* Status Indicators - Only visible when panel is collapsed */}
{!isPanelOpen && (
<View style={styles.statusIndicators}>
{fontSize > 0 && (
<View style={[styles.indicator, { backgroundColor: highContrast ? '#fff' : '#2196F3' }]} />
)}
{highContrast && (
<View style={[styles.indicator, { backgroundColor: highContrast ? '#fff' : '#2196F3' }]} />
)}
{lineHeight > 0 && (
<View style={[styles.indicator, { backgroundColor: highContrast ? '#fff' : '#2196F3' }]} />
)}
{isReading && (
<Animated.View
style={[
styles.indicator,
{ backgroundColor: highContrast ? '#fff' : '#2196F3' }
]}
/>
)}
</View>
)}
</Animated.View>
</GestureHandlerRootView>
);
};
const styles = StyleSheet.create({
gestureContainer: {
flex: 1,
},
container: {
position: 'absolute',
right: 16,
zIndex: 9999,
borderWidth: 2,
borderRadius: 12,
shadowColor: '#000',
shadowOffset: { width: 0, height: 4 },
shadowOpacity: 0.2,
shadowRadius: 8,
elevation: 8,
maxWidth: '90%',
},
header: {
flexDirection: 'row',
justifyContent: 'space-between',
alignItems: 'center',
padding: 8,
marginBottom: 8,
},
titleContainer: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
},
title: {
fontWeight: 'bold',
fontSize: 16,
},
toggleButton: {
padding: 8,
borderRadius: 8,
},
controlsContainer: {
gap: 12,
paddingHorizontal: 8,
},
controlButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 12,
padding: 12,
borderRadius: 8,
},
buttonTextContainer: {
flex: 1,
alignItems: 'flex-start',
},
buttonText: {
fontSize: 14,
textAlign: 'left',
},
buttonSubtext: {
fontSize: 12,
opacity: 0.8,
},
resetButton: {
marginTop: 8,
},
statusIndicators: {
alignItems: 'center',
gap: 8,
marginTop: 8,
paddingHorizontal: 8,
},
indicator: {
width: 8,
height: 8,
borderRadius: 4,
},
});
export default AccessibilitySidebar;