diff --git a/src/components/ui/index.ts b/src/components/ui/index.ts index c245a25..10b2f61 100644 --- a/src/components/ui/index.ts +++ b/src/components/ui/index.ts @@ -5,3 +5,4 @@ export * from "./input"; export * from "./progress"; export * from "./skeleton"; export * from "./tooltip"; +export * from "./kbd"; diff --git a/src/components/ui/kbd.tsx b/src/components/ui/kbd.tsx new file mode 100644 index 0000000..2ef3c13 --- /dev/null +++ b/src/components/ui/kbd.tsx @@ -0,0 +1,18 @@ +import * as React from "react"; + +import { cn } from "@/utils/cn"; + +function Kbd({ className, ...props }: React.ComponentProps<"kbd">) { + return ( + + ); +} + +export { Kbd }; diff --git a/src/features/comparison/components/compare-form.tsx b/src/features/comparison/components/compare-form.tsx index b8e20fc..d980f9f 100644 --- a/src/features/comparison/components/compare-form.tsx +++ b/src/features/comparison/components/compare-form.tsx @@ -4,8 +4,10 @@ import { useEffect, useRef } from "react"; import { ArrowLeftRight, RefreshCw, X } from "lucide-react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Kbd } from "@/components/ui/kbd"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useTranslation } from "@/components/providers/language-provider"; +import { handleEscapeToClear, matchesHotkey, useHotkeys, useIsApplePlatform } from "@/hooks"; import { cn } from "@/utils/cn"; const LANGUAGE_OPTIONS = [ @@ -64,11 +66,21 @@ export function CompareForm({ }: CompareFormProps) { const { t } = useTranslation(); const firstInputRef = useRef(null); + const secondInputRef = useRef(null); + const isApple = useIsApplePlatform(); + const submitShortcutLabel = isApple ? "⌘ ↵" : "Ctrl ↵"; + + // "/" jumps to the first empty field, falling back to the first one when both are filled. + const slashTargetsSecond = Boolean(username1.trim()) && !username2.trim(); useEffect(() => { firstInputRef.current?.focus(); }, []); + useHotkeys({ + "/": () => (slashTargetsSecond ? secondInputRef : firstInputRef).current?.focus(), + }); + const normalized1 = username1.trim().toLowerCase(); const normalized2 = username2.trim().toLowerCase(); const sameUsername = Boolean(normalized1 && normalized2 && normalized1 === normalized2); @@ -101,16 +113,41 @@ export function CompareForm({ setSelectedLanguages([]); }; - const submit = (e: React.FormEvent) => { - e.preventDefault(); + const submitComparison = () => { if (!canSubmit) return; onSubmit(username1.trim(), username2.trim(), { selectedLanguages, }); }; + const submit = (e: React.FormEvent) => { + e.preventDefault(); + submitComparison(); + }; + + const handleFormKeyDown = (e: React.KeyboardEvent) => { + if (!matchesHotkey(e, "mod+enter")) return; + e.preventDefault(); + submitComparison(); + }; + + const handleInputEscape = + (value: string, setValue: (value: string) => void) => + (e: React.KeyboardEvent) => + handleEscapeToClear(e, value, setValue); + + const focusShortcutBadge = ( + + ); + return ( -
+

@@ -125,17 +162,24 @@ export function CompareForm({ - ) => setUsername1(e.target.value)} - aria-label={t("form.username1.label")} - aria-invalid={Boolean(username1Error)} - aria-describedby={username1Error ? "username1-error" : undefined} - /> +

+ ) => + setUsername1(e.target.value) + } + onKeyDown={handleInputEscape(username1, setUsername1)} + aria-label={t("form.username1.label")} + aria-invalid={Boolean(username1Error)} + aria-describedby={username1Error ? "username1-error" : undefined} + aria-keyshortcuts={slashTargetsSecond ? undefined : "/"} + /> + {slashTargetsSecond ? null : focusShortcutBadge} +
{username1Error ? (

{username1Error} @@ -146,16 +190,24 @@ export function CompareForm({ - ) => setUsername2(e.target.value)} - aria-label={t("form.username2.label")} - aria-invalid={Boolean(username2Error)} - aria-describedby={username2Error ? "username2-error" : undefined} - /> +

+ ) => + setUsername2(e.target.value) + } + onKeyDown={handleInputEscape(username2, setUsername2)} + aria-label={t("form.username2.label")} + aria-invalid={Boolean(username2Error)} + aria-describedby={username2Error ? "username2-error" : undefined} + aria-keyshortcuts={slashTargetsSecond ? "/" : undefined} + /> + {slashTargetsSecond ? focusShortcutBadge : null} +
{username2Error ? (

{username2Error} @@ -247,8 +299,16 @@ export function CompareForm({ type="submit" disabled={!canSubmit} className="min-w-[160px] shadow-sm transition-transform hover:-translate-y-0.5" + aria-keyshortcuts="Control+Enter Meta+Enter" + title={t("shortcut.submit", { keys: submitShortcutLabel })} > {loading ? t("form.compare.ing") : t("form.compare")} + + {}} + /> + + ); +} + +describe("CompareForm / shortcut", () => { + afterEach(() => { + cleanup(); + }); + + const pressSlashFromOutside = async (user: ReturnType) => { + await user.click(screen.getByRole("button", { name: "outside" })); + await user.keyboard("/"); + }; + + test("focuses the first field when both are empty", async () => { + const user = userEvent.setup(); + render(); + + await pressSlashFromOutside(user); + + expect(document.activeElement).toBe(screen.getByLabelText("form.username1.label")); + }); + + test("focuses the second field when only the first is filled", async () => { + const user = userEvent.setup(); + render(); + + await pressSlashFromOutside(user); + + const second = screen.getByLabelText("form.username2.label") as HTMLInputElement; + expect(document.activeElement).toBe(second); + expect(second.value).toBe(""); + }); + + test("focuses the first field when only the second is filled", async () => { + const user = userEvent.setup(); + render(); + + await pressSlashFromOutside(user); + + expect(document.activeElement).toBe(screen.getByLabelText("form.username1.label")); + }); + + test("falls back to the first field when both are filled", async () => { + const user = userEvent.setup(); + render(); + + await pressSlashFromOutside(user); + + expect(document.activeElement).toBe(screen.getByLabelText("form.username1.label")); + }); + + test("advertises the shortcut on the field it will focus", () => { + render(); + + expect( + screen.getByLabelText("form.username1.label").getAttribute("aria-keyshortcuts"), + ).toBeNull(); + expect(screen.getByLabelText("form.username2.label").getAttribute("aria-keyshortcuts")).toBe( + "/", + ); + }); +}); diff --git a/src/features/leaderboard/components/country-grid-client.tsx b/src/features/leaderboard/components/country-grid-client.tsx index b5a02c5..03d0965 100644 --- a/src/features/leaderboard/components/country-grid-client.tsx +++ b/src/features/leaderboard/components/country-grid-client.tsx @@ -1,15 +1,15 @@ "use client"; import { useState, useMemo } from "react"; -import { Globe, Search, Users } from "lucide-react"; +import { Globe, Users } from "lucide-react"; import Link from "next/link"; import Image from "next/image"; -import { Input } from "@/components/ui/input"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { useTranslation } from "@/components/providers/language-provider"; import { getCountryCode } from "@/lib/geo"; import type { CountryInfo } from "../types"; import type { Route } from "next"; +import { SearchField } from "./search-field"; type Props = { countries: CountryInfo[]; @@ -58,15 +58,11 @@ export function CountryGridClient({ countries }: Props) { )} {countryList.length > 10 && ( -

- - ) => setSearch(e.target.value)} - /> -
+ )} {filtered.length === 0 && ( diff --git a/src/features/leaderboard/components/leaderboard-table.tsx b/src/features/leaderboard/components/leaderboard-table.tsx index 0aaeaa3..278edee 100644 --- a/src/features/leaderboard/components/leaderboard-table.tsx +++ b/src/features/leaderboard/components/leaderboard-table.tsx @@ -3,14 +3,15 @@ import { useState, useMemo } from "react"; import Link from "next/link"; import type { Route } from "next"; -import { Search, AlertTriangle, ExternalLink, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"; +import { AlertTriangle, ExternalLink, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react"; import { Avatar } from "@/components/layout/avatar"; import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; import { useTranslation } from "@/components/providers/language-provider"; import { cn } from "@/utils/cn"; import type { ScoredLeaderboardEntry as LeaderboardEntry } from "../types"; +import { SearchField } from "./search-field"; + type Props = { users: LeaderboardEntry[]; failedUsers: string[]; @@ -127,15 +128,12 @@ export function LeaderboardTable({ )} {users.length > 0 && ( -
- - ) => setSearch(e.target.value)} - /> -
+ )}
diff --git a/src/features/leaderboard/components/search-field.tsx b/src/features/leaderboard/components/search-field.tsx new file mode 100644 index 0000000..c055d05 --- /dev/null +++ b/src/features/leaderboard/components/search-field.tsx @@ -0,0 +1,48 @@ +"use client"; + +import { useRef } from "react"; +import { Search } from "lucide-react"; +import { Input } from "@/components/ui/input"; +import { Kbd } from "@/components/ui/kbd"; +import { useTranslation } from "@/components/providers/language-provider"; +import { handleEscapeToClear, useHotkeys } from "@/hooks"; +import { cn } from "@/utils/cn"; + +type SearchFieldProps = { + value: string; + onChange: (value: string) => void; + placeholder: string; + className?: string; +}; + +export function SearchField({ value, onChange, placeholder, className }: SearchFieldProps) { + const { t } = useTranslation(); + const inputRef = useRef(null); + + useHotkeys({ "/": () => inputRef.current?.focus() }); + + return ( +
+ + ) => onChange(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => + handleEscapeToClear(e, value, onChange) + } + aria-label={placeholder} + aria-keyshortcuts="/" + /> + +
+ ); +} diff --git a/src/features/leaderboard/tests/leaderboard-table.test.tsx b/src/features/leaderboard/tests/leaderboard-table.test.tsx index c03a493..ade36d2 100644 --- a/src/features/leaderboard/tests/leaderboard-table.test.tsx +++ b/src/features/leaderboard/tests/leaderboard-table.test.tsx @@ -207,3 +207,73 @@ describe("LeaderboardTable sorting", () => { expect(repoHeader?.getAttribute("aria-sort")).toBe("ascending"); }); }); + +describe("LeaderboardTable keyboard shortcuts", () => { + afterEach(() => { + cleanup(); + }); + + const users = [ + { + username: "alice", + name: "Alice", + avatarUrl: "", + impactRank: 1, + finalScore: 80, + repoScore: 30, + prScore: 20, + contributionScore: 40, + }, + { + username: "bob", + name: "Bob", + avatarUrl: "", + impactRank: 2, + finalScore: 70, + repoScore: 90, + prScore: 10, + contributionScore: 30, + }, + ]; + + const renderLeaderboard = () => + render( + , + ); + + test("pressing / focuses the search input", async () => { + const user = userEvent.setup(); + renderLeaderboard(); + + const search = screen.getByPlaceholderText("Search"); + expect(document.activeElement).not.toBe(search); + + await user.keyboard("/"); + + expect(document.activeElement).toBe(search); + expect((search as HTMLInputElement).value).toBe(""); + }); + + test("Escape clears the search first, then leaves the input", async () => { + const user = userEvent.setup(); + renderLeaderboard(); + + const search = screen.getByPlaceholderText("Search") as HTMLInputElement; + await user.type(search, "bob"); + expect(screen.getAllByRole("row")).toHaveLength(2); + + await user.keyboard("{Escape}"); + expect(search.value).toBe(""); + expect(document.activeElement).toBe(search); + expect(screen.getAllByRole("row")).toHaveLength(3); + + await user.keyboard("{Escape}"); + expect(document.activeElement).not.toBe(search); + }); +}); diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 27308be..3694c0f 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1 +1,2 @@ export * from "./use-clipboard-copy"; +export * from "./use-hotkeys"; diff --git a/src/hooks/tests/use-hotkeys.test.ts b/src/hooks/tests/use-hotkeys.test.ts new file mode 100644 index 0000000..e98bb7b --- /dev/null +++ b/src/hooks/tests/use-hotkeys.test.ts @@ -0,0 +1,120 @@ +import { describe, expect, it, vi } from "vitest"; +import { + handleEscapeToClear, + isApplePlatform, + isEditableTarget, + isInsideDialog, + matchesHotkey, +} from "../use-hotkeys"; + +function keyEvent(key: string, overrides: Partial = {}) { + return { + key, + ctrlKey: false, + metaKey: false, + altKey: false, + shiftKey: false, + isComposing: false, + defaultPrevented: false, + ...overrides, + }; +} + +describe("matchesHotkey", () => { + it("matches a plain key without modifiers", () => { + expect(matchesHotkey(keyEvent("/"), "/")).toBe(true); + expect(matchesHotkey(keyEvent("Escape"), "escape")).toBe(true); + }); + + it("does not hijack browser shortcuts that use modifiers", () => { + expect(matchesHotkey(keyEvent("/", { ctrlKey: true }), "/")).toBe(false); + expect(matchesHotkey(keyEvent("/", { metaKey: true }), "/")).toBe(false); + expect(matchesHotkey(keyEvent("/", { altKey: true }), "/")).toBe(false); + }); + + it("allows Shift for symbol keys typed with Shift on some layouts", () => { + expect(matchesHotkey(keyEvent("/", { shiftKey: true }), "/")).toBe(true); + expect(matchesHotkey(keyEvent("Enter", { ctrlKey: true, shiftKey: true }), "mod+enter")).toBe( + false, + ); + }); + + it("treats mod as either Ctrl or Cmd", () => { + expect(matchesHotkey(keyEvent("Enter", { ctrlKey: true }), "mod+enter")).toBe(true); + expect(matchesHotkey(keyEvent("Enter", { metaKey: true }), "mod+enter")).toBe(true); + expect(matchesHotkey(keyEvent("Enter"), "mod+enter")).toBe(false); + }); + + it("ignores keys pressed while an IME composition is active", () => { + expect(matchesHotkey(keyEvent("Escape", { isComposing: true }), "escape")).toBe(false); + }); +}); + +describe("isEditableTarget", () => { + it("detects form fields and contenteditable elements", () => { + expect(isEditableTarget({ tagName: "INPUT" })).toBe(true); + expect(isEditableTarget({ tagName: "TEXTAREA" })).toBe(true); + expect(isEditableTarget({ tagName: "SELECT" })).toBe(true); + expect(isEditableTarget({ tagName: "DIV", isContentEditable: true })).toBe(true); + }); + + it("ignores other elements", () => { + expect(isEditableTarget({ tagName: "BUTTON" })).toBe(false); + expect(isEditableTarget(null)).toBe(false); + }); +}); + +describe("isInsideDialog", () => { + it("uses closest() to find an enclosing dialog", () => { + expect(isInsideDialog({ closest: () => ({}) })).toBe(true); + expect(isInsideDialog({ closest: () => null })).toBe(false); + expect(isInsideDialog(undefined)).toBe(false); + }); +}); + +describe("isApplePlatform", () => { + it("recognises Apple user agents", () => { + expect(isApplePlatform("Mozilla/5.0 (Macintosh; Intel Mac OS X 14_0)")).toBe(true); + expect(isApplePlatform("Mozilla/5.0 (iPad; CPU OS 17_0 like Mac OS X)")).toBe(true); + expect(isApplePlatform("Mozilla/5.0 (Windows NT 10.0; Win64; x64)")).toBe(false); + }); +}); + +describe("handleEscapeToClear", () => { + function fieldEvent(key: string) { + return { ...keyEvent(key), preventDefault: vi.fn(), currentTarget: { blur: vi.fn() } }; + } + + it("clears a filled field on Escape without leaving it", () => { + const event = fieldEvent("Escape"); + const setValue = vi.fn(); + + handleEscapeToClear(event, "octocat", setValue); + + expect(setValue).toHaveBeenCalledWith(""); + expect(event.preventDefault).toHaveBeenCalled(); + expect(event.currentTarget.blur).not.toHaveBeenCalled(); + }); + + it("leaves an empty field on Escape", () => { + const event = fieldEvent("Escape"); + const setValue = vi.fn(); + + handleEscapeToClear(event, "", setValue); + + expect(event.currentTarget.blur).toHaveBeenCalled(); + expect(setValue).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + }); + + it("ignores keys other than Escape", () => { + const event = fieldEvent("a"); + const setValue = vi.fn(); + + handleEscapeToClear(event, "octocat", setValue); + + expect(setValue).not.toHaveBeenCalled(); + expect(event.preventDefault).not.toHaveBeenCalled(); + expect(event.currentTarget.blur).not.toHaveBeenCalled(); + }); +}); diff --git a/src/hooks/use-hotkeys.ts b/src/hooks/use-hotkeys.ts new file mode 100644 index 0000000..9b8af7c --- /dev/null +++ b/src/hooks/use-hotkeys.ts @@ -0,0 +1,111 @@ +"use client"; + +import { useEffect, useRef, useSyncExternalStore } from "react"; + +type HotkeyEvent = Pick & { + isComposing?: boolean; + nativeEvent?: { isComposing?: boolean }; +}; + +type HotkeyTarget = { + tagName?: string; + isContentEditable?: boolean; + closest?: (selector: string) => unknown; +}; + +export function matchesHotkey(event: HotkeyEvent, combo: string): boolean { + if (event.isComposing || event.nativeEvent?.isComposing) return false; + + const parts = combo.toLowerCase().split("+"); + const key = parts[parts.length - 1]; + const wantsMod = parts.includes("mod"); + const hasMod = event.ctrlKey || event.metaKey; + + if (wantsMod !== hasMod || event.altKey) return false; + if (key.length > 1 && event.shiftKey) return false; + + return event.key.toLowerCase() === key; +} + +const EDITABLE_TAGS = new Set(["INPUT", "TEXTAREA", "SELECT"]); +const DIALOG_SELECTOR = 'dialog, [role="dialog"], [role="alertdialog"]'; + +export function isEditableTarget(target: HotkeyTarget | null | undefined): boolean { + if (!target) return false; + return Boolean(target.isContentEditable || (target.tagName && EDITABLE_TAGS.has(target.tagName))); +} + +export function isInsideDialog(target: HotkeyTarget | null | undefined): boolean { + return Boolean(target?.closest?.(DIALOG_SELECTOR)); +} + +type EscapeToClearEvent = HotkeyEvent & { + preventDefault: () => void; + currentTarget: { blur: () => void }; +}; + +// Escape clears the field first, then a second press leaves it. +export function handleEscapeToClear( + event: EscapeToClearEvent, + value: string, + setValue: (value: string) => void, +): void { + if (!matchesHotkey(event, "escape")) return; + if (value) { + event.preventDefault(); + setValue(""); + } else { + event.currentTarget.blur(); + } +} + +export interface UseHotkeysOptions { + enabled?: boolean; +} + +export function useHotkeys( + bindings: Record void>, + options: UseHotkeysOptions = {}, +) { + const { enabled = true } = options; + const bindingsRef = useRef(bindings); + + useEffect(() => { + bindingsRef.current = bindings; + }); + + useEffect(() => { + if (!enabled) return; + + const onKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented) return; + const target = event.target as HotkeyTarget | null; + if (isEditableTarget(target) || isInsideDialog(target)) return; + + for (const [combo, handler] of Object.entries(bindingsRef.current)) { + if (matchesHotkey(event, combo)) { + event.preventDefault(); + handler(event); + return; + } + } + }; + + window.addEventListener("keydown", onKeyDown); + return () => window.removeEventListener("keydown", onKeyDown); + }, [enabled]); +} + +export function isApplePlatform(platform: string): boolean { + return /mac|iphone|ipad|ipod/i.test(platform); +} + +const subscribeNoop = () => () => {}; + +export function useIsApplePlatform(): boolean { + return useSyncExternalStore( + subscribeNoop, + () => isApplePlatform(navigator.userAgent), + () => false, + ); +} diff --git a/src/locales/ar.json b/src/locales/ar.json index 19dd1f6..b440516 100644 --- a/src/locales/ar.json +++ b/src/locales/ar.json @@ -305,5 +305,7 @@ "profile.scoreColor": "لون النتيجة", "profile.transparencyTitle": "إشارات شفافية التقييم", "profile.viewFullStats": "عرض الملف الشخصي للمطور", - "profile.viewOnGithub": "عرض الملف الشخصي على GitHub" + "profile.viewOnGithub": "عرض الملف الشخصي على GitHub", + "shortcut.focusInput": "اضغط / للانتقال هنا", + "shortcut.submit": "اضغط {keys} للمقارنة" } diff --git a/src/locales/en.json b/src/locales/en.json index eff875a..30a903f 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -305,5 +305,7 @@ "profile.scoreColor": "Score Color", "profile.transparencyTitle": "Scoring Transparency Signals", "profile.viewFullStats": "View developer profile", - "profile.viewOnGithub": "View GitHub Profile" + "profile.viewOnGithub": "View GitHub Profile", + "shortcut.focusInput": "Press / to jump here", + "shortcut.submit": "Press {keys} to compare" }