diff --git a/src/components/language-switcher.tsx b/src/components/language-switcher.tsx index 587df62d..6f77160f 100644 --- a/src/components/language-switcher.tsx +++ b/src/components/language-switcher.tsx @@ -4,7 +4,15 @@ import { Check, ChevronDown, Search } from "lucide-react"; import Image from "next/image"; import { useRouter } from "next/navigation"; import { useLocale } from "next-intl"; -import { useEffect, useMemo, useRef, useState, useTransition } from "react"; +import { + useEffect, + useLayoutEffect, + useMemo, + useRef, + useState, + useTransition, +} from "react"; +import { createPortal } from "react-dom"; import { setLocaleCookie } from "@/actions/set-locale"; const LOCALES: { code: string; label: string; lang: string }[] = [ @@ -32,6 +40,10 @@ const LOCALES: { code: string; label: string; lang: string }[] = [ { code: "ru", label: "Русский", lang: "ru" }, ]; +const MENU_WIDTH = 224; // w-56 +const MENU_GAP = 6; +const VIEWPORT_PAD = 8; + export function LanguageSwitcher({ variant = "nav", }: { @@ -42,7 +54,12 @@ export function LanguageSwitcher({ const [pending, startTransition] = useTransition(); const [open, setOpen] = useState(false); const [search, setSearch] = useState(""); - const ref = useRef(null); + const [menuPos, setMenuPos] = useState<{ top: number; left: number } | null>( + null, + ); + const [mounted, setMounted] = useState(false); + const triggerRef = useRef(null); + const menuRef = useRef(null); const searchRef = useRef(null); const current = LOCALES.find((l) => l.code === locale) ?? LOCALES[0]; const isAdmin = variant === "admin"; @@ -59,13 +76,54 @@ export function LanguageSwitcher({ }, [search]); useEffect(() => { + setMounted(true); + }, []); + + useLayoutEffect(() => { + if (!open) { + setMenuPos(null); + return; + } + + function place() { + const trigger = triggerRef.current; + if (!trigger) return; + const rect = trigger.getBoundingClientRect(); + const maxLeft = window.innerWidth - MENU_WIDTH - VIEWPORT_PAD; + const left = Math.min( + Math.max(VIEWPORT_PAD, rect.right - MENU_WIDTH), + Math.max(VIEWPORT_PAD, maxLeft), + ); + setMenuPos({ top: rect.bottom + MENU_GAP, left }); + } + + place(); + window.addEventListener("resize", place); + window.addEventListener("scroll", place, true); + return () => { + window.removeEventListener("resize", place); + window.removeEventListener("scroll", place, true); + }; + }, [open]); + + useEffect(() => { + if (!open) return; function handleClick(e: MouseEvent) { - if (ref.current && !ref.current.contains(e.target as Node)) - setOpen(false); + const t = e.target as Node; + if (triggerRef.current?.contains(t) || menuRef.current?.contains(t)) + return; + setOpen(false); + } + function handleKey(e: KeyboardEvent) { + if (e.key === "Escape") setOpen(false); } document.addEventListener("mousedown", handleClick); - return () => document.removeEventListener("mousedown", handleClick); - }, []); + document.addEventListener("keydown", handleKey); + return () => { + document.removeEventListener("mousedown", handleClick); + document.removeEventListener("keydown", handleKey); + }; + }, [open]); useEffect(() => { if (!open) { @@ -88,9 +146,126 @@ export function LanguageSwitcher({ setOpen(false); } + const menu = open && menuPos && mounted && ( +
+
+ +
+ +
+ {filtered.length === 0 && ( +

+ No languages found +

+ )} + {filtered.map((l) => { + const selected = l.code === locale; + return ( + + ); + })} +
+
+ ); + return ( -
+
- {open && ( -
-
- -
- -
- {filtered.length === 0 && ( -

- No languages found -

- )} - {filtered.map((l) => { - const selected = l.code === locale; - return ( - - ); - })} -
-
- )} + {mounted && menu ? createPortal(menu, document.body) : null}
); }