Files
Astro-Component/src/components/LanguageSwitcher.astro
T
2025-12-08 19:36:01 +05:30

185 lines
6.1 KiB
Plaintext

---
import type { HTMLAttributes } from "astro/types";
import Dropdown from "./Dropdown.astro";
import { Icon } from "astro-icon/components";
interface Language {
code: string;
label: string;
flag?: string;
dir?: "ltr" | "rtl";
}
interface Props extends HTMLAttributes<"div"> {
languages: Language[];
storageKey?: string;
rootSelector?: string;
compact?: boolean;
showLabel?: boolean;
rootExtraClass?: string;
class?: string;
onChange?: string;
}
const {
languages = [{ code: "en", label: "English" }],
storageKey = "wr-lang",
rootSelector = "html",
compact = false,
showLabel = false,
rootExtraClass = "",
class: className,
onChange = "onLanguageChange",
...rest
}: Props = Astro.props;
const containerId = `ls-${Math.random().toString(36).slice(2)}`;
const icon = "wr:size-4";
const item = "wr:flex wr:w-full wr:items-center wr:gap-x-3.5 wr:rounded-lg wr:px-3 wr:py-2 wr:text-sm wr:hover:bg-gray-100 wr:dark:hover:bg-neutral-900 wr:focus:bg-gray-100 focus:outline-hidden dark:hover:bg-neutral-700 dark:hover:text-neutral-300 dark:focus:bg-neutral-700"
const checkBase = "wr:size-4 wr:opacity-0 wr:scale-90 wr:transition";
---
<div id={containerId} class={["wr:inline-flex", className].join(" ")} data-compact={compact ? "1" : ""} {...rest}>
<Dropdown openOn="click" placement="bottom-end" offset={5} shiftPadding={10} showArrow={true} closeOnSelect={true}>
<span slot="trigger" class="wr:inline-flex wr:items-center wr:gap-2">
<span data-current-icon class="wr:inline">
<Icon data-current-flag name="mdi:translate" class={icon} />
</span>
{showLabel && <span data-current-label class="wr:text-sm wr:font-medium">Language</span>}
<svg data-dd-chev class="wr:size-4 wr:opacity-70 wr:transition-transform wr:duration-150" viewBox="0 0 24 24" fill="none">
<path d="M7 10l5 5 5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
</svg>
</span>
{languages.map(lang => (
<button
type="button"
role="menuitemradio"
aria-checked="false"
class={item}
data-dd-item
data-set-lang={lang.code}
data-owner={containerId}
>
<div class="wr:flex wr:items-center wr:gap-3">
<Icon name={lang.flag || "mdi:earth"} class={icon} />
</div>
<div class="wr:flex-1 wr:font-medium">{lang.label}</div>
<Icon name="mdi:check" class={checkBase} data-check />
</button>
))}
</Dropdown>
</div>
<style is:inline>
#{containerId}[data-compact="1"] [data-dd-trigger]{
height: 2.25rem;
padding-left: .5rem;
padding-right: .5rem;
}
/* Show check icon ONLY on active */
#{containerId} [role="menuitemradio"][aria-checked="true"] [data-check]{
opacity: 1 !important;
transform: scale(1) !important;
}
/* Subtle active row styling */
#{containerId} [role="menuitemradio"][aria-checked="true"]{
background-color: color-mix(in hsl, hsl(var(--muted)) 70%, transparent);
}
</style>
<script is:inline define:vars={{ containerId, storageKey, rootSelector, rootExtraClass, onChange, languages }}>
(() => {
const host = document.getElementById(containerId);
if (!host) return;
const root = rootSelector === "html"
? document.documentElement
: document.querySelector(rootSelector) || document.documentElement;
const itemSelector = `[data-set-lang][data-owner="${containerId}"]`;
function reflectUI(code) {
const flagEl = host.querySelector("[data-current-flag]");
if (flagEl) {
const lang = languages.find(l => l.code === code) || { flag: "mdi:earth" };
flagEl.setAttribute("name", lang.flag || "mdi:earth");
}
const labelEl = host.querySelector("[data-current-label]");
if (labelEl) {
const lang = languages.find(l => l.code === code) || { label: code };
labelEl.textContent = lang.label;
}
document.querySelectorAll(itemSelector).forEach(el => {
const active = el.getAttribute("data-set-lang") === code;
el.setAttribute("aria-checked", active ? "true" : "false");
});
}
function setHtmlDirIfNeeded(code) {
const lang = languages.find(l => l.code === code);
if (!lang) return;
if (lang.dir === "rtl") {
root.setAttribute("dir", "rtl");
} else {
root.setAttribute("dir", "ltr");
}
}
function apply(code) {
// Save to localStorage
localStorage.setItem(storageKey, code);
// Save cookie (client-side cookie, non-HttpOnly so JS + server-side dev proxy can read during dev)
// builds an expiry 365 days by default here:
// const days = 365;
// const expires = new Date(Date.now() + days * 24 * 60 * 60 * 1000).toUTCString();
// do NOT include HttpOnly in client-side cookie string
// const cookieStr = `${storageKey}=${encodeURIComponent(code)}; Path=/; Expires=${expires}; SameSite=Lax`;
// document.cookie = cookieStr;
// apply direction if language has dir
setHtmlDirIfNeeded(code);
root.setAttribute("lang", code);
reflectUI(code);
// invoke global callback if present
const fn = window[onChange];
if (typeof fn === "function") {
try { fn(code); } catch (e) { console.warn("onChange handler threw", e); }
}
window.dispatchEvent(new CustomEvent("wr:language:changed", { detail: { code } }));
}
// initialize from (1) cookie, (2) localStorage, (3) navigator.language, (4) first language prop
function readCookie(name) {
const match = document.cookie.split("; ").find(c => c.startsWith(name + "="));
return match ? decodeURIComponent(match.split("=")[1]) : null;
}
const savedCookie = readCookie(storageKey);
const savedLS = localStorage.getItem(storageKey);
const navLang = navigator.language ? navigator.language.split("-")[0] : null;
const initial = savedCookie || savedLS || (languages.some(l => l.code === navLang) ? navLang : languages[0].code);
// initial apply
apply(initial);
// Listen globally (portaled items)
document.addEventListener("click", (e) => {
const btn = e.target?.closest?.(itemSelector);
if (!btn) return;
const code = btn.getAttribute("data-set-lang");
if (!code) return;
apply(code);
});
})();
</script>