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:
parent
619e37f505
commit
d4fc7005d8
|
|
@ -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);
|
||||
Loading…
Reference in New Issue