@@ -4,8 +4,10 @@ import { useEffect, useRef } from "react";
44import { ArrowLeftRight , RefreshCw , X } from "lucide-react" ;
55import { Button } from "@/components/ui/button" ;
66import { Input } from "@/components/ui/input" ;
7+ import { Kbd } from "@/components/ui/kbd" ;
78import { Card , CardContent , CardDescription , CardHeader , CardTitle } from "@/components/ui/card" ;
89import { useTranslation } from "@/components/providers/language-provider" ;
10+ import { handleEscapeToClear , matchesHotkey , useHotkeys , useIsApplePlatform } from "@/hooks" ;
911import { cn } from "@/utils/cn" ;
1012
1113const LANGUAGE_OPTIONS = [
@@ -64,11 +66,21 @@ export function CompareForm({
6466} : CompareFormProps ) {
6567 const { t } = useTranslation ( ) ;
6668 const firstInputRef = useRef < HTMLInputElement > ( null ) ;
69+ const secondInputRef = useRef < HTMLInputElement > ( null ) ;
70+ const isApple = useIsApplePlatform ( ) ;
71+ const submitShortcutLabel = isApple ? "⌘ ↵" : "Ctrl ↵" ;
72+
73+ // "/" jumps to the first empty field, falling back to the first one when both are filled.
74+ const slashTargetsSecond = Boolean ( username1 . trim ( ) ) && ! username2 . trim ( ) ;
6775
6876 useEffect ( ( ) => {
6977 firstInputRef . current ?. focus ( ) ;
7078 } , [ ] ) ;
7179
80+ useHotkeys ( {
81+ "/" : ( ) => ( slashTargetsSecond ? secondInputRef : firstInputRef ) . current ?. focus ( ) ,
82+ } ) ;
83+
7284 const normalized1 = username1 . trim ( ) . toLowerCase ( ) ;
7385 const normalized2 = username2 . trim ( ) . toLowerCase ( ) ;
7486 const sameUsername = Boolean ( normalized1 && normalized2 && normalized1 === normalized2 ) ;
@@ -101,16 +113,41 @@ export function CompareForm({
101113 setSelectedLanguages ( [ ] ) ;
102114 } ;
103115
104- const submit = ( e : React . FormEvent ) => {
105- e . preventDefault ( ) ;
116+ const submitComparison = ( ) => {
106117 if ( ! canSubmit ) return ;
107118 onSubmit ( username1 . trim ( ) , username2 . trim ( ) , {
108119 selectedLanguages,
109120 } ) ;
110121 } ;
111122
123+ const submit = ( e : React . FormEvent ) => {
124+ e . preventDefault ( ) ;
125+ submitComparison ( ) ;
126+ } ;
127+
128+ const handleFormKeyDown = ( e : React . KeyboardEvent < HTMLFormElement > ) => {
129+ if ( ! matchesHotkey ( e , "mod+enter" ) ) return ;
130+ e . preventDefault ( ) ;
131+ submitComparison ( ) ;
132+ } ;
133+
134+ const handleInputEscape =
135+ ( value : string , setValue : ( value : string ) => void ) =>
136+ ( e : React . KeyboardEvent < HTMLInputElement > ) =>
137+ handleEscapeToClear ( e , value , setValue ) ;
138+
139+ const focusShortcutBadge = (
140+ < Kbd
141+ aria-hidden = "true"
142+ title = { t ( "shortcut.focusInput" ) }
143+ className = "absolute end-3 top-1/2 hidden -translate-y-1/2 peer-focus:hidden sm:inline-flex"
144+ >
145+ /
146+ </ Kbd >
147+ ) ;
148+
112149 return (
113- < form onSubmit = { submit } >
150+ < form onSubmit = { submit } onKeyDown = { handleFormKeyDown } >
114151 < Card className = "border-0 p-6 shadow-lg backdrop-blur-sm" >
115152 < CardHeader className = "pb-4" >
116153 < p className = "text-xs font-semibold uppercase tracking-[0.18em] text-primary/80" >
@@ -125,17 +162,24 @@ export function CompareForm({
125162 < label htmlFor = "username1" className = "text-xs font-semibold text-muted-foreground" >
126163 { t ( "form.username1.label" ) }
127164 </ label >
128- < Input
129- id = "username1"
130- className = "h-11"
131- ref = { firstInputRef }
132- placeholder = { t ( "form.username1" ) }
133- value = { username1 }
134- onChange = { ( e : React . ChangeEvent < HTMLInputElement > ) => setUsername1 ( e . target . value ) }
135- aria-label = { t ( "form.username1.label" ) }
136- aria-invalid = { Boolean ( username1Error ) }
137- aria-describedby = { username1Error ? "username1-error" : undefined }
138- />
165+ < div className = "relative" >
166+ < Input
167+ id = "username1"
168+ className = "peer h-11 pe-10"
169+ ref = { firstInputRef }
170+ placeholder = { t ( "form.username1" ) }
171+ value = { username1 }
172+ onChange = { ( e : React . ChangeEvent < HTMLInputElement > ) =>
173+ setUsername1 ( e . target . value )
174+ }
175+ onKeyDown = { handleInputEscape ( username1 , setUsername1 ) }
176+ aria-label = { t ( "form.username1.label" ) }
177+ aria-invalid = { Boolean ( username1Error ) }
178+ aria-describedby = { username1Error ? "username1-error" : undefined }
179+ aria-keyshortcuts = { slashTargetsSecond ? undefined : "/" }
180+ />
181+ { slashTargetsSecond ? null : focusShortcutBadge }
182+ </ div >
139183 { username1Error ? (
140184 < p id = "username1-error" className = "text-xs font-medium text-destructive" >
141185 { username1Error }
@@ -146,16 +190,24 @@ export function CompareForm({
146190 < label htmlFor = "username2" className = "text-xs font-semibold text-muted-foreground" >
147191 { t ( "form.username2.label" ) }
148192 </ label >
149- < Input
150- id = "username2"
151- className = "h-11"
152- placeholder = { t ( "form.username2" ) }
153- value = { username2 }
154- onChange = { ( e : React . ChangeEvent < HTMLInputElement > ) => setUsername2 ( e . target . value ) }
155- aria-label = { t ( "form.username2.label" ) }
156- aria-invalid = { Boolean ( username2Error ) }
157- aria-describedby = { username2Error ? "username2-error" : undefined }
158- />
193+ < div className = "relative" >
194+ < Input
195+ id = "username2"
196+ className = "peer h-11 pe-10"
197+ ref = { secondInputRef }
198+ placeholder = { t ( "form.username2" ) }
199+ value = { username2 }
200+ onChange = { ( e : React . ChangeEvent < HTMLInputElement > ) =>
201+ setUsername2 ( e . target . value )
202+ }
203+ onKeyDown = { handleInputEscape ( username2 , setUsername2 ) }
204+ aria-label = { t ( "form.username2.label" ) }
205+ aria-invalid = { Boolean ( username2Error ) }
206+ aria-describedby = { username2Error ? "username2-error" : undefined }
207+ aria-keyshortcuts = { slashTargetsSecond ? "/" : undefined }
208+ />
209+ { slashTargetsSecond ? focusShortcutBadge : null }
210+ </ div >
159211 { username2Error ? (
160212 < p id = "username2-error" className = "text-xs font-medium text-destructive" >
161213 { username2Error }
@@ -247,8 +299,16 @@ export function CompareForm({
247299 type = "submit"
248300 disabled = { ! canSubmit }
249301 className = "min-w-[160px] shadow-sm transition-transform hover:-translate-y-0.5"
302+ aria-keyshortcuts = "Control+Enter Meta+Enter"
303+ title = { t ( "shortcut.submit" , { keys : submitShortcutLabel } ) }
250304 >
251305 { loading ? t ( "form.compare.ing" ) : t ( "form.compare" ) }
306+ < Kbd
307+ aria-hidden = "true"
308+ className = "ms-2 hidden border-primary-foreground/30 bg-primary-foreground/15 text-primary-foreground sm:inline-flex"
309+ >
310+ { submitShortcutLabel }
311+ </ Kbd >
252312 </ Button >
253313 < Button
254314 onClick = { swapUsers }
0 commit comments