Skip to content

Commit 95e1a89

Browse files
authored
Merge pull request #397 from edehvictor/codex/feat-chat-shortcut-303
feat(frontend): add chat submit keyboard shortcut
2 parents c68f1af + 66a3156 commit 95e1a89

3 files changed

Lines changed: 71 additions & 22 deletions

File tree

dex_with_fiat_frontend/src/components/ChatInput.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,11 @@ export default function ChatInput({
3232
const { t } = useTranslation();
3333
const { connection } = useStellarWallet();
3434
const activePlaceholder = placeholder || t('chat.placeholder');
35+
const isApplePlatform =
36+
typeof navigator !== 'undefined' &&
37+
/(Mac|iPhone|iPad|iPod)/i.test(navigator.platform);
38+
const submitShortcutLabel = isApplePlatform ? 'Cmd+Enter' : 'Ctrl+Enter';
39+
const submitShortcutKeys = isApplePlatform ? 'Meta+Enter' : 'Control+Enter';
3540
const [message, setMessage] = useState('');
3641
const [showCommands, setShowCommands] = useState(false);
3742
const [selectedIndex, setSelectedIndex] = useState(0);
@@ -67,15 +72,15 @@ export default function ChatInput({
6772
};
6873

6974
const [walletWarning, setWalletWarning] = useState(false);
75+
const isSubmitDisabled = !message.trim() || isLoading || isSubmitting;
7076

71-
const handleSubmit = (e: React.FormEvent) => {
72-
e.preventDefault();
77+
const submitMessage = () => {
7378
if (!connection.isConnected) {
7479
setWalletWarning(true);
7580
return;
7681
}
7782
setWalletWarning(false);
78-
if (message.trim() && !isLoading && !isSubmitting) {
83+
if (!isSubmitDisabled) {
7984
executeSubmit(async () => {
8085
onSendMessage(message.trim());
8186
setMessage('');
@@ -85,6 +90,11 @@ export default function ChatInput({
8590
}
8691
};
8792

93+
const handleSubmit = (e: React.FormEvent) => {
94+
e.preventDefault();
95+
submitMessage();
96+
};
97+
8898
useEffect(() => {
8999
if (connection.isConnected) {
90100
setWalletWarning(false);
@@ -110,9 +120,9 @@ export default function ChatInput({
110120
return;
111121
}
112122

113-
if (e.key === 'Enter' && !e.shiftKey) {
123+
if ((e.metaKey || e.ctrlKey) && e.key === 'Enter') {
114124
e.preventDefault();
115-
handleSubmit(e);
125+
submitMessage();
116126
}
117127
};
118128

@@ -292,6 +302,7 @@ export default function ChatInput({
292302
onKeyDown={handleKeyDown}
293303
placeholder={activePlaceholder}
294304
disabled={isLoading}
305+
aria-describedby="chat-submit-shortcut"
295306
className="theme-input w-full resize-none border rounded-lg px-4 py-3 transition-all duration-200 focus:outline-none focus:ring-2 focus:ring-blue-500 disabled:opacity-50 disabled:cursor-not-allowed"
296307
rows={1}
297308
style={{
@@ -309,7 +320,11 @@ export default function ChatInput({
309320

310321
<button
311322
type="submit"
312-
disabled={!message.trim() || isLoading || isSubmitting}
323+
disabled={isSubmitDisabled}
324+
title={`Send message (${submitShortcutLabel})`}
325+
aria-label={`Send message (${submitShortcutLabel})`}
326+
aria-describedby="chat-submit-shortcut"
327+
aria-keyshortcuts={submitShortcutKeys}
313328
className="theme-primary-button flex items-center justify-center w-12 h-12 disabled:bg-gray-300 text-white rounded-lg transition-all duration-200 disabled:cursor-not-allowed transform hover:scale-105 disabled:hover:scale-100 shadow-lg"
314329
>
315330
{isLoading || isSubmitting ? (
@@ -320,6 +335,10 @@ export default function ChatInput({
320335
</button>
321336
</div>
322337

338+
<p id="chat-submit-shortcut" className="sr-only" aria-live="polite">
339+
Send message with {submitShortcutLabel}. The send button stays disabled while a request is pending.
340+
</p>
341+
323342
{walletWarning && (
324343
<div className="flex items-center gap-2 mt-2 px-3 py-2 rounded-lg bg-amber-50 dark:bg-amber-950/40 border border-amber-200 dark:border-amber-700 text-amber-800 dark:text-amber-200 text-xs">
325344
<AlertTriangle className="w-4 h-4 flex-shrink-0" />

dex_with_fiat_frontend/src/components/__tests__/ChatInput.rapid-click.test.tsx

Lines changed: 43 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -33,17 +33,17 @@ describe('ChatInput - Rapid Click Protection', () => {
3333
jest.restoreAllMocks();
3434
});
3535

36-
it('should prevent duplicate message submissions on rapid Enter key presses', async () => {
36+
it('should prevent duplicate message submissions on rapid keyboard shortcut presses', async () => {
3737
render(<ChatInput {...defaultProps} />);
3838
const textarea = screen.getByPlaceholderText('Type a message...');
3939

4040
// Type a message
4141
fireEvent.change(textarea, { target: { value: 'Test message' } });
4242

43-
// Rapidly press Enter multiple times
44-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
45-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
46-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
43+
// Rapidly press Ctrl+Enter multiple times
44+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
45+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
46+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
4747

4848
await waitFor(() => {
4949
// Should only send once
@@ -59,7 +59,9 @@ describe('ChatInput - Rapid Click Protection', () => {
5959
// Type a message
6060
fireEvent.change(textarea, { target: { value: 'Test message' } });
6161

62-
const submitButton = screen.getByRole('button', { name: /submit/i });
62+
const submitButton = screen.getByRole('button', {
63+
name: /send message \(ctrl\+enter\)/i,
64+
});
6365

6466
// Rapidly click the submit button
6567
fireEvent.click(submitButton);
@@ -79,7 +81,9 @@ describe('ChatInput - Rapid Click Protection', () => {
7981

8082
fireEvent.change(textarea, { target: { value: 'Test message' } });
8183

82-
const submitButton = screen.getByRole('button', { name: /submit/i });
84+
const submitButton = screen.getByRole('button', {
85+
name: /send message \(ctrl\+enter\)/i,
86+
});
8387

8488
// Click submit
8589
fireEvent.click(submitButton);
@@ -96,7 +100,7 @@ describe('ChatInput - Rapid Click Protection', () => {
96100

97101
// First submission
98102
fireEvent.change(textarea, { target: { value: 'First message' } });
99-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
103+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
100104

101105
await waitFor(() => {
102106
expect(mockOnSendMessage).toHaveBeenCalledTimes(1);
@@ -107,7 +111,7 @@ describe('ChatInput - Rapid Click Protection', () => {
107111

108112
// Second submission
109113
fireEvent.change(textarea, { target: { value: 'Second message' } });
110-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
114+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
111115

112116
await waitFor(() => {
113117
expect(mockOnSendMessage).toHaveBeenCalledTimes(2);
@@ -120,7 +124,7 @@ describe('ChatInput - Rapid Click Protection', () => {
120124
const textarea = screen.getByPlaceholderText('Type a message...');
121125

122126
fireEvent.change(textarea, { target: { value: 'Test message' } });
123-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
127+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
124128

125129
expect(mockOnSendMessage).not.toHaveBeenCalled();
126130
});
@@ -134,8 +138,8 @@ describe('ChatInput - Rapid Click Protection', () => {
134138
fireEvent.change(textarea, { target: { value: 'Test message' } });
135139

136140
// Rapid submissions
137-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
138-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
141+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
142+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
139143

140144
await waitFor(() => {
141145
expect(consoleWarnSpy).toHaveBeenCalledWith(
@@ -152,7 +156,7 @@ describe('ChatInput - Rapid Click Protection', () => {
152156
fireEvent.change(textarea, { target: { value: 'Test message' } });
153157
expect(textarea.value).toBe('Test message');
154158

155-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
159+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
156160

157161
await waitFor(() => {
158162
expect(textarea.value).toBe('');
@@ -177,4 +181,30 @@ describe('ChatInput - Rapid Click Protection', () => {
177181
expect(mockOnSendMessage).toHaveBeenCalledTimes(1);
178182
});
179183
});
184+
185+
it('should not submit on Enter without the modifier shortcut', () => {
186+
render(<ChatInput {...defaultProps} />);
187+
const textarea = screen.getByPlaceholderText('Type a message...');
188+
189+
fireEvent.change(textarea, { target: { value: 'Test message' } });
190+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
191+
192+
expect(mockOnSendMessage).not.toHaveBeenCalled();
193+
});
194+
195+
it('should expose the shortcut through the submit button tooltip and label', () => {
196+
render(<ChatInput {...defaultProps} />);
197+
const textarea = screen.getByPlaceholderText('Type a message...');
198+
199+
fireEvent.change(textarea, { target: { value: 'Test message' } });
200+
201+
const submitButton = screen.getByRole('button', {
202+
name: /send message \(ctrl\+enter\)/i,
203+
});
204+
205+
expect(submitButton).toHaveAttribute('title', 'Send message (Ctrl+Enter)');
206+
expect(submitButton).toHaveAttribute('aria-keyshortcuts', 'Control+Enter');
207+
expect(textarea).toHaveAttribute('aria-describedby', 'chat-submit-shortcut');
208+
expect(screen.getByText(/send message with ctrl\+enter/i)).toBeInTheDocument();
209+
});
180210
});

dex_with_fiat_frontend/src/components/__tests__/ChatInput.wallet-disconnect.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ describe('ChatInput - Wallet Disconnect Handling', () => {
6161
const textarea = screen.getByPlaceholderText('Type a message...');
6262

6363
fireEvent.change(textarea, { target: { value: 'Test message' } });
64-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
64+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
6565

6666
expect(screen.getByText('Wallet disconnected. Reconnect to continue.')).toBeInTheDocument();
6767
expect(mockOnSendMessage).not.toHaveBeenCalled();
@@ -72,7 +72,7 @@ describe('ChatInput - Wallet Disconnect Handling', () => {
7272
const textarea = screen.getByPlaceholderText('Type a message...');
7373

7474
fireEvent.change(textarea, { target: { value: 'Test message' } });
75-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
75+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
7676

7777
expect(mockOnSendMessage).not.toHaveBeenCalled();
7878
});
@@ -82,7 +82,7 @@ describe('ChatInput - Wallet Disconnect Handling', () => {
8282
const textarea = screen.getByPlaceholderText('Type a message...');
8383

8484
fireEvent.change(textarea, { target: { value: 'Test message' } });
85-
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter' });
85+
fireEvent.keyDown(textarea, { key: 'Enter', code: 'Enter', ctrlKey: true });
8686

8787
expect(screen.getByText('Wallet disconnected. Reconnect to continue.')).toBeInTheDocument();
8888

0 commit comments

Comments
 (0)