accessibility-sidebar
Version:
A comprehensive, lightweight accessibility widget for web and mobile apps with WCAG 2.1 AA compliance
808 lines (735 loc) • 28.4 kB
JSX
// Enhanced Accessibility Sidebar with Narrator - Standalone JSX Component
window.AccessibilitySidebar = () => {
// State for panel visibility and settings
const [isPanelOpen, setIsPanelOpen] = React.useState(false);
const [isDragging, setIsDragging] = React.useState(false);
const [position, setPosition] = React.useState({ x: 16, y: 100 });
const [startPos, setStartPos] = React.useState({ x: 0, y: 0 });
const [isMobile, setIsMobile] = React.useState(false);
// Voice and narrator states
const [availableRomanianVoices, setAvailableRomanianVoices] = React.useState([]);
const [selectedVoice, setSelectedVoice] = React.useState(null);
const [readingProgress, setReadingProgress] = React.useState(0);
const [currentUtterance, setCurrentUtterance] = React.useState(null);
const [speechRate, setSpeechRate] = React.useState(0.8);
const [speechPitch, setSpeechPitch] = React.useState(1.0);
// Accessibility states
const [fontSize, setFontSize] = React.useState(0); // 0: normal, 1: larger, 2: largest
const [highContrast, setHighContrast] = React.useState(false);
const [lineHeight, setLineHeight] = React.useState(0); // 0: normal, 1: larger, 2: largest
const [isReading, setIsReading] = React.useState(false);
// Check for mobile devices
React.useEffect(() => {
const checkMobile = () => {
setIsMobile(window.innerWidth <= 768);
};
checkMobile();
window.addEventListener('resize', checkMobile);
return () => window.removeEventListener('resize', checkMobile);
}, []);
// Voice setup and management
React.useEffect(() => {
const updateVoices = () => {
const voices = window.speechSynthesis.getVoices();
const romanianVoices = voices.filter(voice =>
voice.lang.includes('ro') ||
voice.name.toLowerCase().includes('romanian') ||
voice.name.toLowerCase().includes('română')
);
setAvailableRomanianVoices(romanianVoices);
// Auto-select the best Romanian voice
if (romanianVoices.length > 0 && !selectedVoice) {
const bestVoice = romanianVoices.find(v =>
!v.name.toLowerCase().includes('standard') &&
!v.name.toLowerCase().includes('compact')
) || romanianVoices[0];
setSelectedVoice(bestVoice);
}
};
updateVoices();
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = updateVoices;
}
return () => {
if (window.speechSynthesis.onvoiceschanged !== undefined) {
window.speechSynthesis.onvoiceschanged = null;
}
};
}, [selectedVoice]);
// Handle font size changes
const handleFontSizeChange = () => {
const newSize = (fontSize + 1) % 3;
setFontSize(newSize);
// Remove existing classes
document.body.classList.remove('font-size-larger', 'font-size-largest');
// Add appropriate class
if (newSize === 1) {
document.body.classList.add('font-size-larger');
} else if (newSize === 2) {
document.body.classList.add('font-size-largest');
}
};
// Handle contrast toggle
const handleContrastToggle = () => {
const newState = !highContrast;
setHighContrast(newState);
if (newState) {
document.body.classList.add('high-contrast');
} else {
document.body.classList.remove('high-contrast');
}
};
// Handle line height changes
const handleLineHeightChange = () => {
const newHeight = (lineHeight + 1) % 3;
setLineHeight(newHeight);
// Remove existing classes
document.body.classList.remove('line-height-larger', 'line-height-largest');
// Add appropriate class
if (newHeight === 1) {
document.body.classList.add('line-height-larger');
} else if (newHeight === 2) {
document.body.classList.add('line-height-largest');
}
};
// Enhanced text-to-speech with narrator features
const handleReadAloud = () => {
if (!('speechSynthesis' in window)) {
alert('Browserul dvs. nu suportă citirea cu voce tare');
return;
}
if (isReading) {
window.speechSynthesis.cancel();
setIsReading(false);
setReadingProgress(0);
setCurrentUtterance(null);
} else {
// Get all the text from the main content
const contentArea = document.querySelector('.content-area') || document.querySelector('main') || document.body;
// Get text content from relevant elements, excluding navigation and controls
const textElements = contentArea.querySelectorAll('p, li, h1, h2, h3, h4, h5, h6, blockquote, td');
const textContent = Array.from(textElements)
.filter(el => {
// Exclude navigation, accessibility controls, and other UI elements
return !el.closest('.toc') &&
!el.closest('.content-nav') &&
!el.closest('.accessibility-sidebar') &&
!el.closest('.back-to-top') &&
!el.closest('.search-container') &&
!el.closest('nav') &&
!el.closest('header') &&
!el.closest('footer') &&
!el.classList.contains('sr-only');
})
.map(el => el.textContent.trim())
.filter(text => text.length > 0)
.join('. ');
if (!textContent) {
alert('Nu s-a găsit conținut pentru citire');
return;
}
// Split into manageable chunks for better speech synthesis
const sentences = textContent.match(/[^\.!?]+[\.!?]+/g) || [textContent];
const chunks = [];
let currentChunk = '';
sentences.forEach(sentence => {
if ((currentChunk + sentence).length > 200) {
if (currentChunk) chunks.push(currentChunk.trim());
currentChunk = sentence;
} else {
currentChunk += sentence;
}
});
if (currentChunk) chunks.push(currentChunk.trim());
let currentIndex = 0;
const speakNext = () => {
if (currentIndex < chunks.length && isReading) {
const utterance = new SpeechSynthesisUtterance(chunks[currentIndex]);
utterance.lang = 'ro-RO';
utterance.rate = speechRate;
utterance.pitch = speechPitch;
utterance.volume = 1.0;
// Use selected voice if available
if (selectedVoice) {
utterance.voice = selectedVoice;
}
utterance.onstart = () => {
const progress = Math.round((currentIndex / chunks.length) * 100);
setReadingProgress(progress);
};
utterance.onend = () => {
currentIndex++;
if (currentIndex < chunks.length) {
// Small pause between chunks
setTimeout(speakNext, 300);
} else {
setIsReading(false);
setReadingProgress(100);
// Reset progress after completion
setTimeout(() => setReadingProgress(0), 2000);
}
};
utterance.onerror = (event) => {
console.error('Speech synthesis error:', event.error);
setIsReading(false);
setReadingProgress(0);
alert('Eroare la citirea cu voce tare: ' + event.error);
};
setCurrentUtterance(utterance);
window.speechSynthesis.speak(utterance);
}
};
setIsReading(true);
setReadingProgress(0);
// Cancel any previous speech
window.speechSynthesis.cancel();
// Start speaking
setTimeout(speakNext, 100); // Small delay to ensure cancellation is complete
}
};
// Handle speech rate change
const handleSpeechRateChange = () => {
const rates = [0.6, 0.8, 1.0, 1.2];
const currentIndex = rates.indexOf(speechRate);
const newRate = rates[(currentIndex + 1) % rates.length];
setSpeechRate(newRate);
};
// Handle voice selection
const handleVoiceChange = () => {
if (availableRomanianVoices.length <= 1) return;
const currentIndex = availableRomanianVoices.indexOf(selectedVoice);
const nextIndex = (currentIndex + 1) % availableRomanianVoices.length;
setSelectedVoice(availableRomanianVoices[nextIndex]);
};
// Handle panel dragging
const handleMouseDown = (e) => {
if (isMobile) return; // No dragging on mobile
setIsDragging(true);
setStartPos({
x: e.clientX - position.x,
y: e.clientY - position.y
});
// Prevent text selection during drag
e.preventDefault();
};
const handleMouseMove = (e) => {
if (!isDragging) return;
// Calculate new position with bounds checking
const newX = Math.max(0, Math.min(window.innerWidth - 60, e.clientX - startPos.x));
const newY = Math.max(0, Math.min(window.innerHeight - 60, e.clientY - startPos.y));
setPosition({ x: newX, y: newY });
};
const handleMouseUp = () => {
setIsDragging(false);
};
// Set up mouse event listeners for dragging
React.useEffect(() => {
if (isDragging) {
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
} else {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
}
return () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
}, [isDragging]);
// Reset position when switching between mobile and desktop
React.useEffect(() => {
if (isMobile) {
setPosition({ x: 16, y: window.innerHeight - 80 });
} else {
setPosition({ x: 16, y: 100 });
}
}, [isMobile]);
// Handle keyboard accessibility
const handleKeyDown = (e, action) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
action();
}
};
// Reset all settings
const resetAllSettings = () => {
setFontSize(0);
setHighContrast(false);
setLineHeight(0);
setSpeechRate(0.8);
setSpeechPitch(1.0);
if (isReading) {
window.speechSynthesis.cancel();
setIsReading(false);
setReadingProgress(0);
}
// Remove all classes
document.body.classList.remove(
'font-size-larger',
'font-size-largest',
'high-contrast',
'line-height-larger',
'line-height-largest'
);
};
// Get current voice name for display
const getCurrentVoiceName = () => {
if (!selectedVoice) return 'Implicit';
return selectedVoice.name.length > 20
? selectedVoice.name.substring(0, 20) + '...'
: selectedVoice.name;
};
// Get speech rate label
const getSpeechRateLabel = () => {
switch (speechRate) {
case 0.6: return 'Încet';
case 0.8: return 'Normal';
case 1.0: return 'Mediu';
case 1.2: return 'Rapid';
default: return 'Normal';
}
};
return (
<React.Fragment>
{/* Enhanced CSS for animations and narrator features */}
<style>
{`
@keyframes pulse {
0% { opacity: 0.4; }
50% { opacity: 1; }
100% { opacity: 0.4; }
}
@keyframes reading-progress {
0% { transform: scaleX(0); }
100% { transform: scaleX(1); }
}
@media (prefers-reduced-motion: reduce) {
.accessibility-sidebar {
transition: none !important;
}
@keyframes pulse {
0%, 50%, 100% { opacity: 1; }
}
}
.a11y-icon {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
}
.a11y-icon.large {
width: 24px;
height: 24px;
}
.reading-progress-indicator {
position: absolute;
bottom: 0;
left: 0;
height: 3px;
background: linear-gradient(90deg, #4CAF50, #2196F3);
border-radius: 0 0 12px 12px;
transition: width 0.3s ease;
}
.voice-indicator {
font-size: 10px;
opacity: 0.7;
margin-top: 2px;
line-height: 1;
}
`}
</style>
<div
className={`accessibility-sidebar ${isPanelOpen ? 'expanded' : 'collapsed'} ${isMobile ? 'mobile' : 'desktop'}`}
style={{
position: 'fixed',
top: isMobile ? 'auto' : `${position.y}px`,
left: isMobile ? 'auto' : `${position.x}px`,
bottom: isMobile ? '20px' : 'auto',
right: isMobile ? '20px' : 'auto',
zIndex: 9999,
transition: 'all 0.3s ease',
background: highContrast ? '#000' : 'white',
color: highContrast ? '#fff' : '#333',
border: `2px solid ${highContrast ? '#fff' : '#2196F3'}`,
borderRadius: '12px',
boxShadow: '0 4px 8px rgba(0, 0, 0, 0.2)',
padding: isPanelOpen ? '16px' : '8px',
width: isPanelOpen ? (isMobile ? '300px' : '300px') : '56px',
maxWidth: '90vw',
display: 'flex',
flexDirection: 'column',
gap: '12px',
cursor: isDragging ? 'grabbing' : 'auto',
position: 'relative'
}}
>
{/* Reading progress indicator */}
{isReading && readingProgress > 0 && (
<div
className="reading-progress-indicator"
style={{
width: `${readingProgress}%`
}}
/>
)}
{/* Panel Header */}
<div
className="accessibility-header"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '8px',
cursor: isMobile ? 'auto' : 'grab',
padding: '4px'
}}
onMouseDown={handleMouseDown}
role="presentation"
>
{isPanelOpen && (
<div className="title" style={{ fontWeight: 'bold', display: 'flex', alignItems: 'center', gap: '8px' }}>
<div className="a11y-icon">
<i className="fas fa-universal-access" aria-hidden="true"></i>
</div>
<span>Accesibilitate</span>
</div>
)}
<button
aria-label={isPanelOpen ? "Închide panoul de accesibilitate" : "Deschide panoul de accesibilitate"}
title={isPanelOpen ? "Închide panoul" : "Opțiuni de accesibilitate"}
onClick={() => setIsPanelOpen(!isPanelOpen)}
style={{
background: 'transparent',
border: 'none',
cursor: 'pointer',
padding: '8px',
borderRadius: '8px',
color: highContrast ? '#fff' : '#2196F3',
marginLeft: isPanelOpen ? '0' : 'auto',
marginRight: isPanelOpen ? '0' : 'auto',
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
}}
onKeyDown={(e) => handleKeyDown(e, () => setIsPanelOpen(!isPanelOpen))}
>
{isPanelOpen ? (
<div className="a11y-icon large">
<i className="fas fa-compress" aria-hidden="true"></i>
</div>
) : (
<div className="a11y-icon large">
<i className="fas fa-universal-access" aria-hidden="true"></i>
</div>
)}
</button>
</div>
{/* Control Buttons - Only visible when panel is expanded */}
{isPanelOpen && (
<div
className="control-buttons"
style={{
display: 'flex',
flexDirection: 'column',
gap: '12px'
}}
role="group"
aria-label="Controale de accesibilitate"
>
{/* Font Size Control */}
<button
aria-label={`Mărime text: ${fontSize === 0 ? 'normal' : fontSize === 1 ? 'mare' : 'foarte mare'}`}
aria-pressed={fontSize > 0}
className={`control-button ${fontSize > 0 ? 'active' : ''}`}
onClick={handleFontSizeChange}
onKeyDown={(e) => handleKeyDown(e, handleFontSizeChange)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 12px',
borderRadius: '8px',
border: 'none',
background: fontSize > 0 ? (highContrast ? '#fff' : '#e3f2fd') : (highContrast ? '#333' : '#f5f5f5'),
color: fontSize > 0 ? (highContrast ? '#000' : '#2196F3') : (highContrast ? '#fff' : '#333'),
cursor: 'pointer',
textAlign: 'left',
fontWeight: fontSize > 0 ? 'bold' : 'normal',
transition: 'all 0.2s ease',
width: '100%'
}}
>
<div className="a11y-icon">
<i className="fas fa-font" aria-hidden="true"></i>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<span>Mărime text</span>
<small style={{ fontSize: '12px', opacity: '0.8' }}>
{fontSize === 0 ? 'Normal' : fontSize === 1 ? 'Mare' : 'Foarte mare'}
</small>
</div>
</button>
{/* Contrast Control */}
<button
aria-label={`Contrast: ${highContrast ? 'ridicat' : 'normal'}`}
aria-pressed={highContrast}
className={`control-button ${highContrast ? 'active' : ''}`}
onClick={handleContrastToggle}
onKeyDown={(e) => handleKeyDown(e, handleContrastToggle)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 12px',
borderRadius: '8px',
border: 'none',
background: highContrast ? (highContrast ? '#fff' : '#e3f2fd') : (highContrast ? '#333' : '#f5f5f5'),
color: highContrast ? (highContrast ? '#000' : '#2196F3') : (highContrast ? '#fff' : '#333'),
cursor: 'pointer',
textAlign: 'left',
fontWeight: highContrast ? 'bold' : 'normal',
transition: 'all 0.2s ease',
width: '100%'
}}
>
<div className="a11y-icon">
<i className={highContrast ? "fas fa-sun" : "fas fa-moon"} aria-hidden="true"></i>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<span>Contrast</span>
<small style={{ fontSize: '12px', opacity: '0.8' }}>
{highContrast ? 'Ridicat' : 'Normal'}
</small>
</div>
</button>
{/* Line Height Control */}
<button
aria-label={`Spațiu între rânduri: ${lineHeight === 0 ? 'normal' : lineHeight === 1 ? 'mare' : 'foarte mare'}`}
aria-pressed={lineHeight > 0}
className={`control-button ${lineHeight > 0 ? 'active' : ''}`}
onClick={handleLineHeightChange}
onKeyDown={(e) => handleKeyDown(e, handleLineHeightChange)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 12px',
borderRadius: '8px',
border: 'none',
background: lineHeight > 0 ? (highContrast ? '#fff' : '#e3f2fd') : (highContrast ? '#333' : '#f5f5f5'),
color: lineHeight > 0 ? (highContrast ? '#000' : '#2196F3') : (highContrast ? '#fff' : '#333'),
cursor: 'pointer',
textAlign: 'left',
fontWeight: lineHeight > 0 ? 'bold' : 'normal',
transition: 'all 0.2s ease',
width: '100%'
}}
>
<div className="a11y-icon">
<i className="fas fa-align-justify" aria-hidden="true"></i>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<span>Spațiu între rânduri</span>
<small style={{ fontSize: '12px', opacity: '0.8' }}>
{lineHeight === 0 ? 'Normal' : lineHeight === 1 ? 'Mare' : 'Foarte mare'}
</small>
</div>
</button>
{/* Text-to-Speech Control */}
<button
aria-label={isReading ? "Oprește citirea" : "Citește cu voce tare"}
aria-pressed={isReading}
className={`control-button ${isReading ? 'active' : ''}`}
onClick={handleReadAloud}
onKeyDown={(e) => handleKeyDown(e, handleReadAloud)}
disabled={!('speechSynthesis' in window)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 12px',
borderRadius: '8px',
border: 'none',
background: isReading ? (highContrast ? '#fff' : '#e3f2fd') : (highContrast ? '#333' : '#f5f5f5'),
color: isReading ? (highContrast ? '#000' : '#2196F3') : (highContrast ? '#fff' : '#333'),
cursor: 'speechSynthesis' in window ? 'pointer' : 'not-allowed',
textAlign: 'left',
fontWeight: isReading ? 'bold' : 'normal',
opacity: 'speechSynthesis' in window ? 1 : 0.7,
transition: 'all 0.2s ease',
width: '100%'
}}
>
<div className="a11y-icon">
<i className={isReading ? "fas fa-volume-mute" : "fas fa-volume-up"} aria-hidden="true"></i>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<span>{isReading ? "Oprește citirea" : "Citește cu voce tare"}</span>
<small style={{ fontSize: '12px', opacity: '0.8' }}>
{'speechSynthesis' in window
? (isReading ? `Progres: ${readingProgress}%` : 'Inactiv')
: 'Indisponibil'}
</small>
</div>
</button>
{/* Speech Rate Control */}
{availableRomanianVoices.length > 0 && (
<button
aria-label={`Viteza citirii: ${getSpeechRateLabel()}`}
onClick={handleSpeechRateChange}
onKeyDown={(e) => handleKeyDown(e, handleSpeechRateChange)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 12px',
borderRadius: '8px',
border: 'none',
background: highContrast ? '#333' : '#f5f5f5',
color: highContrast ? '#fff' : '#333',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.2s ease',
width: '100%'
}}
>
<div className="a11y-icon">
<i className="fas fa-tachometer-alt" aria-hidden="true"></i>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<span>Viteza citirii</span>
<small style={{ fontSize: '12px', opacity: '0.8' }}>
{getSpeechRateLabel()}
</small>
</div>
</button>
)}
{/* Voice Selection */}
{availableRomanianVoices.length > 1 && (
<button
aria-label={`Voce: ${getCurrentVoiceName()}`}
onClick={handleVoiceChange}
onKeyDown={(e) => handleKeyDown(e, handleVoiceChange)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 12px',
borderRadius: '8px',
border: 'none',
background: highContrast ? '#333' : '#f5f5f5',
color: highContrast ? '#fff' : '#333',
cursor: 'pointer',
textAlign: 'left',
transition: 'all 0.2s ease',
width: '100%'
}}
>
<div className="a11y-icon">
<i className="fas fa-microphone" aria-hidden="true"></i>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start' }}>
<span>Voce narrator</span>
<small style={{ fontSize: '12px', opacity: '0.8' }}>
{getCurrentVoiceName()}
</small>
</div>
</button>
)}
{/* Reset All Settings */}
<button
aria-label="Resetează toate setările"
className="control-button reset"
onClick={resetAllSettings}
onKeyDown={(e) => handleKeyDown(e, resetAllSettings)}
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '10px 12px',
borderRadius: '8px',
border: 'none',
background: highContrast ? '#444' : '#f5f5f5',
color: highContrast ? '#fff' : '#666',
cursor: 'pointer',
textAlign: 'left',
marginTop: '8px',
transition: 'all 0.2s ease',
width: '100%'
}}
>
<div className="a11y-icon">
<i className="fas fa-undo" aria-hidden="true"></i>
</div>
<span>Resetează setările</span>
</button>
</div>
)}
{/* Collapsed state status indicators */}
{!isPanelOpen && (
<div
className="status-indicators"
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
gap: '8px',
marginTop: '8px'
}}
>
{fontSize > 0 && (
<div
aria-hidden="true"
title="Mărime text mărită"
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: highContrast ? '#fff' : '#2196F3'
}}
/>
)}
{highContrast && (
<div
aria-hidden="true"
title="Contrast ridicat activat"
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: highContrast ? '#fff' : '#2196F3'
}}
/>
)}
{lineHeight > 0 && (
<div
aria-hidden="true"
title="Spațiu între rânduri mărit"
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: highContrast ? '#fff' : '#2196F3'
}}
/>
)}
{isReading && (
<div
aria-hidden="true"
title="Citire vocală activă"
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: highContrast ? '#fff' : '#2196F3',
animation: 'pulse 1.5s infinite'
}}
/>
)}
</div>
)}
</div>
</React.Fragment>
);
};