Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/components/ui/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export * from "./input";
export * from "./progress";
export * from "./skeleton";
export * from "./tooltip";
export * from "./kbd";
18 changes: 18 additions & 0 deletions src/components/ui/kbd.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import * as React from "react";

import { cn } from "@/utils/cn";

function Kbd({ className, ...props }: React.ComponentProps<"kbd">) {
return (
<kbd
data-slot="kbd"
className={cn(
"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",
className,
)}
{...props}
/>
);
}

export { Kbd };
108 changes: 84 additions & 24 deletions src/features/comparison/components/compare-form.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -64,11 +66,21 @@ export function CompareForm({
}: CompareFormProps) {
const { t } = useTranslation();
const firstInputRef = useRef<HTMLInputElement>(null);
const secondInputRef = useRef<HTMLInputElement>(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);
Expand Down Expand Up @@ -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<HTMLFormElement>) => {
if (!matchesHotkey(e, "mod+enter")) return;
e.preventDefault();
submitComparison();
};

const handleInputEscape =
(value: string, setValue: (value: string) => void) =>
(e: React.KeyboardEvent<HTMLInputElement>) =>
handleEscapeToClear(e, value, setValue);

const focusShortcutBadge = (
<Kbd
aria-hidden="true"
title={t("shortcut.focusInput")}
className="absolute end-3 top-1/2 hidden -translate-y-1/2 peer-focus:hidden sm:inline-flex"
>
/
</Kbd>
);

return (
<form onSubmit={submit}>
<form onSubmit={submit} onKeyDown={handleFormKeyDown}>
<Card className="border-0 p-6 shadow-lg backdrop-blur-sm">
<CardHeader className="pb-4">
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-primary/80">
Expand All @@ -125,17 +162,24 @@ export function CompareForm({
<label htmlFor="username1" className="text-xs font-semibold text-muted-foreground">
{t("form.username1.label")}
</label>
<Input
id="username1"
className="h-11"
ref={firstInputRef}
placeholder={t("form.username1")}
value={username1}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername1(e.target.value)}
aria-label={t("form.username1.label")}
aria-invalid={Boolean(username1Error)}
aria-describedby={username1Error ? "username1-error" : undefined}
/>
<div className="relative">
<Input
id="username1"
className="peer h-11 pe-10"
ref={firstInputRef}
placeholder={t("form.username1")}
value={username1}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
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}
</div>
{username1Error ? (
<p id="username1-error" className="text-xs font-medium text-destructive">
{username1Error}
Expand All @@ -146,16 +190,24 @@ export function CompareForm({
<label htmlFor="username2" className="text-xs font-semibold text-muted-foreground">
{t("form.username2.label")}
</label>
<Input
id="username2"
className="h-11"
placeholder={t("form.username2")}
value={username2}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setUsername2(e.target.value)}
aria-label={t("form.username2.label")}
aria-invalid={Boolean(username2Error)}
aria-describedby={username2Error ? "username2-error" : undefined}
/>
<div className="relative">
<Input
id="username2"
className="peer h-11 pe-10"
ref={secondInputRef}
placeholder={t("form.username2")}
value={username2}
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
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}
</div>
{username2Error ? (
<p id="username2-error" className="text-xs font-medium text-destructive">
{username2Error}
Expand Down Expand Up @@ -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")}
<Kbd
aria-hidden="true"
className="ms-2 hidden border-primary-foreground/30 bg-primary-foreground/15 text-primary-foreground sm:inline-flex"
>
{submitShortcutLabel}
</Kbd>
</Button>
<Button
onClick={swapUsers}
Expand Down
92 changes: 92 additions & 0 deletions src/features/comparison/tests/compare-form.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// @vitest-environment jsdom

import { useState } from "react";
import { cleanup, render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { afterEach, describe, expect, test, vi } from "vitest";
import { CompareForm } from "../components/compare-form";

vi.mock("@/components/providers/language-provider", () => ({
useTranslation: () => ({ t: (key: string) => key }),
}));

function ControlledForm({ initial1 = "", initial2 = "" }) {
const [username1, setUsername1] = useState(initial1);
const [username2, setUsername2] = useState(initial2);
const [selectedLanguages, setSelectedLanguages] = useState<string[]>([]);

return (
<>
<button type="button">outside</button>
<CompareForm
username1={username1}
username2={username2}
selectedLanguages={selectedLanguages}
setUsername1={setUsername1}
setUsername2={setUsername2}
setSelectedLanguages={setSelectedLanguages}
onSubmit={() => {}}
/>
</>
);
}

describe("CompareForm / shortcut", () => {
afterEach(() => {
cleanup();
});

const pressSlashFromOutside = async (user: ReturnType<typeof userEvent.setup>) => {
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(<ControlledForm />);

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(<ControlledForm initial1="alice" />);

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(<ControlledForm initial2="bob" />);

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(<ControlledForm initial1="alice" initial2="bob" />);

await pressSlashFromOutside(user);

expect(document.activeElement).toBe(screen.getByLabelText("form.username1.label"));
});

test("advertises the shortcut on the field it will focus", () => {
render(<ControlledForm initial1="alice" />);

expect(
screen.getByLabelText("form.username1.label").getAttribute("aria-keyshortcuts"),
).toBeNull();
expect(screen.getByLabelText("form.username2.label").getAttribute("aria-keyshortcuts")).toBe(
"/",
);
});
});
18 changes: 7 additions & 11 deletions src/features/leaderboard/components/country-grid-client.tsx
Original file line number Diff line number Diff line change
@@ -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[];
Expand Down Expand Up @@ -58,15 +58,11 @@ export function CountryGridClient({ countries }: Props) {
)}

{countryList.length > 10 && (
<div className="relative max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="h-9 pl-9"
placeholder={t("leaderboard.searchCountry")}
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
/>
</div>
<SearchField
value={search}
onChange={setSearch}
placeholder={t("leaderboard.searchCountry")}
/>
)}

{filtered.length === 0 && (
Expand Down
20 changes: 9 additions & 11 deletions src/features/leaderboard/components/leaderboard-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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[];
Expand Down Expand Up @@ -127,15 +128,12 @@ export function LeaderboardTable({
)}
</div>
{users.length > 0 && (
<div className="relative mt-2 max-w-sm">
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
<Input
className="h-9 pl-9"
placeholder={t("leaderboard.search")}
value={search}
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setSearch(e.target.value)}
/>
</div>
<SearchField
className="mt-2"
value={search}
onChange={setSearch}
placeholder={t("leaderboard.search")}
/>
)}
</CardHeader>
<CardContent>
Expand Down
Loading
Loading