Skip to content

Commit 260c267

Browse files
authored
feat(i18n): replace native language select with custom dropdown (#117)
1 parent ee56062 commit 260c267

1 file changed

Lines changed: 98 additions & 11 deletions

File tree

components/language-switcher.tsx

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

3+
import { useEffect, useRef, useState } from "react";
34
import { useTranslation } from "./language-provider";
45
import { cn } from "../lib/utils";
56

67
export function LanguageSwitcher() {
78
const { locale, setLocale, locales, dir } = useTranslation();
9+
const [open, setOpen] = useState(false);
10+
const wrapperRef = useRef<HTMLDivElement>(null);
11+
12+
const currentLocale = locales.find((item) => item.value === locale);
13+
14+
useEffect(() => {
15+
function onPointerDown(event: MouseEvent) {
16+
if (!wrapperRef.current?.contains(event.target as Node)) {
17+
setOpen(false);
18+
}
19+
}
20+
21+
function onEscape(event: KeyboardEvent) {
22+
if (event.key === "Escape") {
23+
setOpen(false);
24+
}
25+
}
26+
27+
document.addEventListener("mousedown", onPointerDown);
28+
document.addEventListener("keydown", onEscape);
29+
30+
return () => {
31+
document.removeEventListener("mousedown", onPointerDown);
32+
document.removeEventListener("keydown", onEscape);
33+
};
34+
}, []);
35+
36+
function onSelect(nextLocale: (typeof locales)[number]["value"]) {
37+
setLocale(nextLocale);
38+
setOpen(false);
39+
}
40+
841
return (
9-
<div className={cn("flex items-center gap-2 text-sm", dir === "rtl" && "flex-row-reverse")}>
10-
<select
11-
className="h-9 rounded-lg border border-input bg-background px-3 text-sm text-foreground focus:outline-none focus:ring-2 focus:ring-primary/60"
12-
value={locale}
13-
onChange={(e) => setLocale(e.target.value as any)}
42+
<div
43+
ref={wrapperRef}
44+
dir={dir}
45+
className={cn("relative flex items-center gap-2 text-sm", dir === "rtl" && "flex-row-reverse")}
46+
>
47+
<button
48+
type="button"
49+
aria-haspopup="listbox"
50+
aria-expanded={open}
51+
aria-label="Select language"
52+
className={cn(
53+
"flex h-9 min-w-[140px] items-center justify-between rounded-lg border border-input bg-background px-3 text-sm text-foreground transition-colors",
54+
"focus:outline-none focus:ring-2 focus:ring-primary/60",
55+
open && "ring-2 ring-primary/60"
56+
)}
57+
onClick={() => setOpen((prev) => !prev)}
1458
>
15-
{locales.map((l) => (
16-
<option key={l.value} value={l.value}>
17-
{l.label}
18-
</option>
19-
))}
20-
</select>
59+
<span>{currentLocale?.label ?? locale}</span>
60+
<svg
61+
viewBox="0 0 24 24"
62+
fill="none"
63+
aria-hidden="true"
64+
className={cn("h-4 w-4 transition-transform", open && "rotate-180")}
65+
>
66+
<path
67+
d="M6 9L12 15L18 9"
68+
stroke="currentColor"
69+
strokeWidth="1.8"
70+
strokeLinecap="round"
71+
strokeLinejoin="round"
72+
/>
73+
</svg>
74+
</button>
75+
76+
{open && (
77+
<ul
78+
role="listbox"
79+
aria-label="Language options"
80+
className={cn(
81+
"absolute top-full z-30 mt-1 max-h-60 min-w-[140px] overflow-auto rounded-lg border border-input bg-background p-1 text-sm text-popover-foreground shadow-md flex flex-col gap-1",
82+
dir === "rtl" ? "left-0" : "right-0"
83+
)}
84+
>
85+
{locales.map((item) => {
86+
const isSelected = item.value === locale;
87+
return (
88+
<li key={item.value}>
89+
<button
90+
type="button"
91+
role="option"
92+
aria-selected={isSelected}
93+
className={cn(
94+
"flex w-full items-center rounded-md px-2 py-1.5 text-left transition-colors",
95+
"hover:bg-accent hover:text-accent-foreground",
96+
"focus:outline-none focus:ring-2 focus:ring-primary/40",
97+
isSelected && "bg-accent text-accent-foreground"
98+
)}
99+
onClick={() => onSelect(item.value)}
100+
>
101+
{item.label}
102+
</button>
103+
</li>
104+
);
105+
})}
106+
</ul>
107+
)}
21108
</div>
22109
);
23110
}

0 commit comments

Comments
 (0)