Skip to content

Commit 3becd1c

Browse files
SamuelMackovychO2sa
authored andcommitted
feat: add keyboard shortcuts for comparison form and table search
1 parent 6ceee81 commit 3becd1c

13 files changed

Lines changed: 567 additions & 48 deletions

File tree

‎src/components/ui/index.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,4 @@ export * from "./input";
55
export * from "./progress";
66
export * from "./skeleton";
77
export * from "./tooltip";
8+
export * from "./kbd";

‎src/components/ui/kbd.tsx‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import * as React from "react";
2+
3+
import { cn } from "@/utils/cn";
4+
5+
function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
6+
return (
7+
<kbd
8+
data-slot="kbd"
9+
className={cn(
10+
"pointer-events-none inline-flex h-5 min-w-5 select-none items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-[11px] font-medium text-muted-foreground",
11+
className,
12+
)}
13+
{...props}
14+
/>
15+
);
16+
}
17+
18+
export { Kbd };

‎src/features/comparison/components/compare-form.tsx‎

Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@ import { useEffect, useRef } from "react";
44
import { ArrowLeftRight, RefreshCw, X } from "lucide-react";
55
import { Button } from "@/components/ui/button";
66
import { Input } from "@/components/ui/input";
7+
import { Kbd } from "@/components/ui/kbd";
78
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
89
import { useTranslation } from "@/components/providers/language-provider";
10+
import { handleEscapeToClear, matchesHotkey, useHotkeys, useIsApplePlatform } from "@/hooks";
911
import { cn } from "@/utils/cn";
1012

1113
const 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}
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
// @vitest-environment jsdom
2+
3+
import { useState } from "react";
4+
import { cleanup, render, screen } from "@testing-library/react";
5+
import userEvent from "@testing-library/user-event";
6+
import { afterEach, describe, expect, test, vi } from "vitest";
7+
import { CompareForm } from "../components/compare-form";
8+
9+
vi.mock("@/components/providers/language-provider", () => ({
10+
useTranslation: () => ({ t: (key: string) => key }),
11+
}));
12+
13+
function ControlledForm({ initial1 = "", initial2 = "" }) {
14+
const [username1, setUsername1] = useState(initial1);
15+
const [username2, setUsername2] = useState(initial2);
16+
const [selectedLanguages, setSelectedLanguages] = useState<string[]>([]);
17+
18+
return (
19+
<>
20+
<button type="button">outside</button>
21+
<CompareForm
22+
username1={username1}
23+
username2={username2}
24+
selectedLanguages={selectedLanguages}
25+
setUsername1={setUsername1}
26+
setUsername2={setUsername2}
27+
setSelectedLanguages={setSelectedLanguages}
28+
onSubmit={() => {}}
29+
/>
30+
</>
31+
);
32+
}
33+
34+
describe("CompareForm / shortcut", () => {
35+
afterEach(() => {
36+
cleanup();
37+
});
38+
39+
const pressSlashFromOutside = async (user: ReturnType<typeof userEvent.setup>) => {
40+
await user.click(screen.getByRole("button", { name: "outside" }));
41+
await user.keyboard("/");
42+
};
43+
44+
test("focuses the first field when both are empty", async () => {
45+
const user = userEvent.setup();
46+
render(<ControlledForm />);
47+
48+
await pressSlashFromOutside(user);
49+
50+
expect(document.activeElement).toBe(screen.getByLabelText("form.username1.label"));
51+
});
52+
53+
test("focuses the second field when only the first is filled", async () => {
54+
const user = userEvent.setup();
55+
render(<ControlledForm initial1="alice" />);
56+
57+
await pressSlashFromOutside(user);
58+
59+
const second = screen.getByLabelText("form.username2.label") as HTMLInputElement;
60+
expect(document.activeElement).toBe(second);
61+
expect(second.value).toBe("");
62+
});
63+
64+
test("focuses the first field when only the second is filled", async () => {
65+
const user = userEvent.setup();
66+
render(<ControlledForm initial2="bob" />);
67+
68+
await pressSlashFromOutside(user);
69+
70+
expect(document.activeElement).toBe(screen.getByLabelText("form.username1.label"));
71+
});
72+
73+
test("falls back to the first field when both are filled", async () => {
74+
const user = userEvent.setup();
75+
render(<ControlledForm initial1="alice" initial2="bob" />);
76+
77+
await pressSlashFromOutside(user);
78+
79+
expect(document.activeElement).toBe(screen.getByLabelText("form.username1.label"));
80+
});
81+
82+
test("advertises the shortcut on the field it will focus", () => {
83+
render(<ControlledForm initial1="alice" />);
84+
85+
expect(
86+
screen.getByLabelText("form.username1.label").getAttribute("aria-keyshortcuts"),
87+
).toBeNull();
88+
expect(screen.getByLabelText("form.username2.label").getAttribute("aria-keyshortcuts")).toBe(
89+
"/",
90+
);
91+
});
92+
});

‎src/features/leaderboard/components/country-grid-client.tsx‎

Lines changed: 7 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
"use client";
22

33
import { useState, useMemo } from "react";
4-
import { Globe, Search, Users } from "lucide-react";
4+
import { Globe, Users } from "lucide-react";
55
import Link from "next/link";
66
import Image from "next/image";
7-
import { Input } from "@/components/ui/input";
87
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
98
import { useTranslation } from "@/components/providers/language-provider";
109
import { getCountryCode } from "@/lib/geo";
1110
import type { CountryInfo } from "../types";
1211
import type { Route } from "next";
12+
import { SearchField } from "./search-field";
1313

1414
type Props = {
1515
countries: CountryInfo[];
@@ -58,15 +58,11 @@ export function CountryGridClient({ countries }: Props) {
5858
)}
5959

6060
{countryList.length > 10 && (
61-
<div className="relative max-w-sm">
62-
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
63-
<Input
64-
className="h-9 pl-9"
65-
placeholder={t("leaderboard.searchCountry")}
66-
value={search}
67-
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
68-
/>
69-
</div>
61+
<SearchField
62+
value={search}
63+
onChange={setSearch}
64+
placeholder={t("leaderboard.searchCountry")}
65+
/>
7066
)}
7167

7268
{filtered.length === 0 && (

‎src/features/leaderboard/components/leaderboard-table.tsx‎

Lines changed: 9 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,15 @@
33
import { useState, useMemo } from "react";
44
import Link from "next/link";
55
import type { Route } from "next";
6-
import { Search, AlertTriangle, ExternalLink, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
6+
import { AlertTriangle, ExternalLink, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
77
import { Avatar } from "@/components/layout/avatar";
88
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
99
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
10-
import { Input } from "@/components/ui/input";
1110
import { useTranslation } from "@/components/providers/language-provider";
1211
import { cn } from "@/utils/cn";
1312
import type { ScoredLeaderboardEntry as LeaderboardEntry } from "../types";
13+
import { SearchField } from "./search-field";
14+
1415
type Props = {
1516
users: LeaderboardEntry[];
1617
failedUsers: string[];
@@ -127,15 +128,12 @@ export function LeaderboardTable({
127128
)}
128129
</div>
129130
{users.length > 0 && (
130-
<div className="relative mt-2 max-w-sm">
131-
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
132-
<Input
133-
className="h-9 pl-9"
134-
placeholder={t("leaderboard.search")}
135-
value={search}
136-
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
137-
/>
138-
</div>
131+
<SearchField
132+
className="mt-2"
133+
value={search}
134+
onChange={setSearch}
135+
placeholder={t("leaderboard.search")}
136+
/>
139137
)}
140138
</CardHeader>
141139
<CardContent>

0 commit comments

Comments
 (0)