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:
parent
6ce7473850
commit
d39dcf36fd
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue