feat(09-01): create JKChatComposer with validation and keyboard handling

- Controlled textarea for message input (1-255 chars after trim)
- Character count display (X/255) with color-coded feedback
- Enter to send, Shift+Enter for newline
- Disabled states: disconnected, sending, invalid input
- Validation messages for error states
- Error display for send failures
- React.memo and useCallback optimizations

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Nuwan 2026-01-27 15:21:21 +05:30
parent 619e37f505
commit d4fc7005d8
1 changed files with 206 additions and 0 deletions

View File

@ -0,0 +1,206 @@
import React, { useState, useCallback, useMemo } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { sendMessage, selectSendStatus, selectSendError } from '../../../store/features/sessionChatSlice';
import { selectSessionId } from '../../../store/features/activeSessionSlice';
import { useJamServerContext } from '../../../context/JamServerContext';
/**
* JKChatComposer - Message composition component
*
* Features:
* - Controlled textarea for message input
* - Character count display (X/255) with color-coded feedback
* - Validation: 1-255 characters after trim
* - Keyboard handling: Enter to send, Shift+Enter for newline
* - Disabled states: disconnected, sending, invalid input
* - Error display for send failures
* - Disconnected warning
*
* Validation levels:
* - Gray: 0-230 characters (normal)
* - Yellow: 231-255 characters (approaching limit)
* - Red: 256+ characters (over limit)
*/
const JKChatComposer = () => {
const dispatch = useDispatch();
const [inputText, setInputText] = useState('');
// Redux selectors
const sessionId = useSelector(selectSessionId);
const sendStatus = useSelector(selectSendStatus);
const sendError = useSelector(selectSendError);
// WebSocket connection status
const { isConnected } = useJamServerContext();
// Validation and state calculations
const trimmedText = useMemo(() => inputText.trim(), [inputText]);
const charCount = inputText.length;
const isOverLimit = charCount > 255;
const isNearLimit = charCount > 230 && charCount <= 255; // 90% threshold
const isValid = trimmedText.length > 0 && trimmedText.length <= 255;
const isSending = sendStatus === 'loading';
const canSend = isValid && isConnected && !isSending;
// Character count color
const countColor = isOverLimit ? '#dc3545' : isNearLimit ? '#ffc107' : '#6c757d';
// Validation message
const getValidationMessage = useCallback(() => {
if (isOverLimit) {
return `Message is ${charCount - 255} characters too long`;
}
if (!isConnected) {
return 'Waiting for connection...';
}
if (trimmedText.length === 0 && charCount > 0) {
return 'Message cannot be empty';
}
return null;
}, [isOverLimit, charCount, isConnected, trimmedText.length]);
const validationMessage = getValidationMessage();
// Event handlers
const handleInputChange = useCallback(e => {
setInputText(e.target.value);
}, []);
const handleSend = useCallback(async () => {
if (!canSend) return;
// Send message via Redux thunk
await dispatch(
sendMessage({
channel: sessionId,
sessionId,
message: trimmedText
})
);
// Clear input on success
setInputText('');
}, [canSend, dispatch, sessionId, trimmedText]);
const handleKeyDown = useCallback(
e => {
// Enter without Shift: send message
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault(); // Prevent newline insertion
handleSend();
}
// Shift+Enter: allow default (insert newline)
},
[handleSend]
);
return (
<div style={{ padding: '12px', borderTop: '1px solid #ddd' }}>
{/* Textarea */}
<textarea
value={inputText}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder="Type a message..."
rows={3}
style={{
width: '100%',
padding: '8px',
border: '1px solid #ccc',
borderRadius: '4px',
resize: 'none',
fontFamily: 'inherit',
fontSize: '14px',
boxSizing: 'border-box'
}}
disabled={!isConnected || isSending}
/>
{/* Character count and send button row */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginTop: '8px'
}}
>
{/* Character count with validation message */}
<div style={{ display: 'flex', flexDirection: 'column', flex: 1 }}>
<span
style={{
fontSize: '12px',
color: countColor,
fontWeight: isOverLimit || isNearLimit ? 'bold' : 'normal'
}}
>
{charCount}/255
</span>
{validationMessage && (
<span
style={{
fontSize: '11px',
color: isOverLimit ? '#dc3545' : '#ffc107',
marginTop: '2px'
}}
>
{validationMessage}
</span>
)}
</div>
{/* Send button */}
<button
onClick={handleSend}
disabled={!canSend}
style={{
padding: '6px 16px',
backgroundColor: canSend ? '#007bff' : '#ccc',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: canSend ? 'pointer' : 'not-allowed',
fontSize: '14px',
fontWeight: '500'
}}
>
{isSending ? 'Sending...' : 'Send'}
</button>
</div>
{/* Error display */}
{sendError && (
<div
style={{
marginTop: '8px',
padding: '8px',
backgroundColor: '#f8d7da',
color: '#721c24',
borderRadius: '4px',
fontSize: '12px'
}}
>
Failed to send message. Please try again.
</div>
)}
{/* Disconnected warning */}
{!isConnected && (
<div
style={{
marginTop: '8px',
padding: '8px',
backgroundColor: '#fff3cd',
color: '#856404',
borderRadius: '4px',
fontSize: '12px'
}}
>
Disconnected. Reconnecting...
</div>
)}
</div>
);
};
export default React.memo(JKChatComposer);