feat(11-01): add accessibility and keyboard navigation

Add comprehensive ARIA attributes, keyboard shortcuts, and focus management for chat interface.

- JKSessionChatWindow: Add role="dialog", aria-labelledby, Escape key handler, auto-focus textarea
- JKChatComposer: Add aria-label, aria-describedby, aria-invalid, role="alert" for validation
- JKChatHeader: Add id="chat-window-title" for aria-labelledby reference
- JKSessionChatButton: Add aria-label with unread count, aria-pressed state, role="button", tabIndex
- Focus management: Textarea receives focus when window opens (100ms delay for DOM)
- Keyboard navigation: Escape closes window, Tab order preserved, Enter sends message

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nuwan 2026-01-27 23:05:04 +05:30
parent 6ce7473850
commit d39dcf36fd
4 changed files with 51 additions and 7 deletions

View File

@ -42,6 +42,10 @@ const JKSessionChatButton = ({ sessionId }) => {
src={chatIcon}
alt="Chat"
onClick={handleClick}
role="button"
tabIndex={0}
aria-label={`Open chat${unreadCount > 0 ? ` (${unreadCount} unread)` : ''}`}
aria-pressed={isWindowOpen}
style={{
cursor: 'pointer',
width: '24px',

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react';
import React, { useCallback, useEffect, useRef } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import WindowPortal from '../common/WindowPortal.js';
import JKChatHeader from './chat/JKChatHeader.js';
@ -32,6 +32,7 @@ import { useJamServerContext } from '../../context/JamServerContext';
*/
const JKSessionChatWindow = () => {
const dispatch = useDispatch();
const textareaRef = useRef(null);
// Redux selectors
const isWindowOpen = useSelector(selectIsChatWindowOpen);
@ -54,6 +55,29 @@ const JKSessionChatWindow = () => {
return 'Session Chat';
}, [activeChannel]);
// Keyboard shortcut: Escape to close window
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
dispatch(closeChatWindow());
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [dispatch]);
// Focus management: Auto-focus textarea when window opens
useEffect(() => {
if (isWindowOpen && textareaRef.current) {
// Delay focus to ensure DOM is ready
setTimeout(() => {
if (textareaRef.current) {
textareaRef.current.focus();
}
}, 100);
}
}, [isWindowOpen]);
// Conditional render: only show if window is open
if (!isWindowOpen) return null;
@ -64,7 +88,12 @@ const JKSessionChatWindow = () => {
onClose={handleClose}
windowId="jamkazam-chat"
>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}>
<div
role="dialog"
aria-labelledby="chat-window-title"
aria-modal="false"
style={{ display: 'flex', flexDirection: 'column', height: '100%', width: '100%' }}
>
<JKChatHeader
channelName={getChannelName()}
onClose={handleClose}
@ -87,7 +116,7 @@ const JKSessionChatWindow = () => {
<div style={{ flex: 1, overflowY: 'auto' }}>
<JKChatMessageList />
</div>
<JKChatComposer />
<JKChatComposer textareaRef={textareaRef} />
</div>
</WindowPortal>
);

View File

@ -20,8 +20,11 @@ import { useJamServerContext } from '../../../context/JamServerContext';
* - Gray: 0-230 characters (normal)
* - Yellow: 231-255 characters (approaching limit)
* - Red: 256+ characters (over limit)
*
* @param {Object} props - Component props
* @param {React.Ref} props.textareaRef - Ref for textarea (for focus management)
*/
const JKChatComposer = () => {
const JKChatComposer = ({ textareaRef }) => {
const dispatch = useDispatch();
const [inputText, setInputText] = useState('');
@ -102,11 +105,15 @@ const JKChatComposer = () => {
<div style={{ padding: '12px', borderTop: '1px solid #ddd' }}>
{/* Textarea */}
<textarea
ref={textareaRef}
value={inputText}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={3}
aria-label="Type your message"
aria-describedby="char-count"
aria-invalid={isOverLimit}
style={{
width: '100%',
padding: '8px',
@ -132,6 +139,7 @@ const JKChatComposer = () => {
{/* Character count with validation message */}
<div style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
<span
id="char-count"
style={{
fontSize: '12px',
color: countColor,
@ -141,7 +149,10 @@ const JKChatComposer = () => {
{charCount}/255
</span>
{validationMessage && (
<span
<div
role="alert"
aria-live="polite"
aria-atomic="true"
style={{
fontSize: '11px',
color: isOverLimit ? '#dc3545' : '#ffc107',
@ -149,7 +160,7 @@ const JKChatComposer = () => {
}}
>
{validationMessage}
</span>
</div>
)}
</div>

View File

@ -21,7 +21,7 @@ const JKChatHeader = ({ channelName, onClose }) => {
borderBottom: '1px solid #e0e0e0',
backgroundColor: '#f8f9fa'
}}>
<h5 style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>
<h5 id="chat-window-title" style={{ margin: 0, fontSize: '16px', fontWeight: 600 }}>
{channelName || 'Session Chat'}
</h5>
<button