Aggiunge menu lingua dropdown e base i18n IT/EN/TH.

Introduce routing multilingua con locale tailandese, selettore lingua a tendina con icone bandiera SVG e gestione chiusura click-outside/Esc, oltre a integrazione Turnstile lato form contatti.

Made-with: Cursor
This commit is contained in:
Javaxman
2026-04-22 22:49:46 +02:00
parent fd6eaac03a
commit a81ed6f76b
34 changed files with 407 additions and 12 deletions

View File

@ -37,4 +37,11 @@ export default defineConfig({
adapter: cloudflare(), adapter: cloudflare(),
integrations: [alpinejs()], integrations: [alpinejs()],
i18n: {
defaultLocale: 'it',
locales: ['it', 'en', 'th'],
routing: {
prefixDefaultLocale: false,
},
},
}); });

View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" aria-hidden="true">
<rect width="48" height="32" fill="#012169" />
<path d="M0 0l48 32M48 0L0 32" stroke="#fff" stroke-width="6" />
<path d="M24 0v32M0 16h48" stroke="#fff" stroke-width="10" />
<path d="M24 0v32M0 16h48" stroke="#c8102e" stroke-width="6" />
</svg>

After

Width:  |  Height:  |  Size: 333 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" aria-hidden="true">
<rect width="16" height="32" x="0" y="0" fill="#009246" />
<rect width="16" height="32" x="16" y="0" fill="#ffffff" />
<rect width="16" height="32" x="32" y="0" fill="#ce2b37" />
</svg>

After

Width:  |  Height:  |  Size: 272 B

View File

@ -0,0 +1,5 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 48 32" aria-hidden="true">
<rect width="48" height="32" x="0" y="0" fill="#a51931" />
<rect width="48" height="24" x="0" y="4" fill="#ffffff" />
<rect width="48" height="16" x="0" y="8" fill="#2d2a4a" />
</svg>

After

Width:  |  Height:  |  Size: 270 B

View File

@ -5,6 +5,7 @@ const cf = cta.contactForm;
const model = { const model = {
actionUrl: cf.actionUrl, actionUrl: cf.actionUrl,
mailto: cf.mailto, mailto: cf.mailto,
turnstileSiteKey: cf.turnstileSiteKey,
notifySubject: cf.notifySubject, notifySubject: cf.notifySubject,
successRedirect: cf.successRedirect, successRedirect: cf.successRedirect,
fieldName: cf.fieldName, fieldName: cf.fieldName,
@ -15,6 +16,8 @@ const model = {
}; };
const hasFormspree = const hasFormspree =
typeof cf.actionUrl === 'string' && cf.actionUrl.length > 0; typeof cf.actionUrl === 'string' && cf.actionUrl.length > 0;
const hasTurnstile =
typeof cf.turnstileSiteKey === 'string' && cf.turnstileSiteKey.length > 0;
const nextUrl = const nextUrl =
typeof cf.successRedirect === 'string' && cf.successRedirect.length > 0 typeof cf.successRedirect === 'string' && cf.successRedirect.length > 0
? cf.successRedirect ? cf.successRedirect
@ -178,6 +181,23 @@ const fieldClass =
> >
</p> </p>
{
hasFormspree && hasTurnstile ? (
<div class="pt-1">
<div
class="cf-turnstile"
data-sitekey={cf.turnstileSiteKey}
data-theme="dark"
data-language="it"
data-size="normal"
></div>
<p class="mt-1.5 text-xs leading-relaxed text-nx-muted">
Verifica anti-spam: completa il controllo prima di inviare il messaggio.
</p>
</div>
) : null
}
<div class="pt-1"> <div class="pt-1">
<button <button
type="submit" type="submit"
@ -191,6 +211,16 @@ const fieldClass =
</div> </div>
</div> </div>
{
hasFormspree && hasTurnstile ? (
<script
src="https://challenges.cloudflare.com/turnstile/v0/api.js"
async
defer
></script>
) : null
}
<script> <script>
import '../scripts/nx-contact-form.ts'; import '../scripts/nx-contact-form.ts';
</script> </script>

View File

@ -17,6 +17,16 @@ const notifySubject =
const linkClass = const linkClass =
'text-sm text-sky-400/90 transition-colors hover:text-sky-300'; 'text-sm text-sky-400/90 transition-colors hover:text-sky-300';
const path = Astro.url.pathname;
const localeMatch = path.match(/^\/(en|th)(\/|$)/);
const currentLocale = localeMatch?.[1] ?? 'it';
const withLocale = (href: string) => {
if (currentLocale === 'it') return href;
if (href.startsWith(`/${currentLocale}`)) return href;
if (href.startsWith('/#')) return `/${currentLocale}${href}`;
if (href.startsWith('/')) return `/${currentLocale}${href}`;
return href;
};
--- ---
<footer id="site-footer" class="relative z-[1] border-t border-nx-border px-4 py-12 sm:px-6"> <footer id="site-footer" class="relative z-[1] border-t border-nx-border px-4 py-12 sm:px-6">
@ -35,7 +45,7 @@ const linkClass =
<ul class="space-y-2.5"> <ul class="space-y-2.5">
{footer.homeNav.map((link) => ( {footer.homeNav.map((link) => (
<li> <li>
<a class={linkClass} href={link.href}> <a class={linkClass} href={withLocale(link.href)}>
{link.label} {link.label}
</a> </a>
</li> </li>
@ -52,7 +62,7 @@ const linkClass =
<ul class="space-y-2.5"> <ul class="space-y-2.5">
{footer.legalColumnNav.map((link) => ( {footer.legalColumnNav.map((link) => (
<li> <li>
<a class={linkClass} href={link.href}> <a class={linkClass} href={withLocale(link.href)}>
{link.label} {link.label}
</a> </a>
</li> </li>
@ -101,7 +111,7 @@ const linkClass =
{footer.companyNav.map((link) => ( {footer.companyNav.map((link) => (
<li> <li>
{link.href ? ( {link.href ? (
<a class={linkClass} href={link.href}> <a class={linkClass} href={withLocale(link.href)}>
{link.label} {link.label}
</a> </a>
) : ( ) : (

View File

@ -30,6 +30,11 @@ export const cta = {
actionUrl: '', actionUrl: '',
/** Formspree: redirect dopo invio (`_next`) — URL **assoluta** in produzione */ /** Formspree: redirect dopo invio (`_next`) — URL **assoluta** in produzione */
successRedirect: '', successRedirect: '',
/**
* Cloudflare Turnstile (anti-spam): inserisci qui la site key pubblica.
* Se vuota, il CAPTCHA non viene mostrato.
*/
turnstileSiteKey: '',
/** Formspree `_subject` e oggetto mailto */ /** Formspree `_subject` e oggetto mailto */
notifySubject: 'Richiesta contatto — sito NexStudio', notifySubject: 'Richiesta contatto — sito NexStudio',
/** Se `actionUrl` è vuoto: email per `mailto:` (es. `info@tuodominio.com`) */ /** Se `actionUrl` è vuoto: email per `mailto:` (es. `info@tuodominio.com`) */

View File

@ -12,10 +12,13 @@ const {
title = 'NexStudio — Software su misura', title = 'NexStudio — Software su misura',
description = 'Studio di sviluppo software: web app, API, automazioni e prodotti digitali, con focus su qualità e time-to-market.', description = 'Studio di sviluppo software: web app, API, automazioni e prodotti digitali, con focus su qualità e time-to-market.',
} = Astro.props; } = Astro.props;
const pathname = Astro.url.pathname;
const localeMatch = pathname.match(/^\/(en|th)(\/|$)/);
const htmlLang = localeMatch?.[1] ?? 'it';
--- ---
<!doctype html> <!doctype html>
<html lang="it" class="bg-nx-bg"> <html lang={htmlLang} class="bg-nx-bg">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />

View File

@ -27,6 +27,26 @@ const printDate = new Intl.DateTimeFormat('it-IT', {
dateStyle: 'long', dateStyle: 'long',
}).format(new Date()); }).format(new Date());
const printSource = Astro.url.href; const printSource = Astro.url.href;
const path = Astro.url.pathname;
const localeMatch = path.match(/^\/(en|th)(\/|$)/);
const currentLocale = localeMatch?.[1] ?? 'it';
const strippedPath = path.replace(/^\/(en|th)(?=\/|$)/, '') || '/';
const localeHref = (locale: 'it' | 'en' | 'th') => {
if (locale === 'it') return strippedPath;
return `/${locale}${strippedPath === '/' ? '' : strippedPath}`;
};
const localize = (href: string) => {
if (currentLocale === 'it') return href;
if (href.startsWith(`/${currentLocale}`)) return href;
if (href.startsWith('/#')) return `/${currentLocale}${href}`;
if (href.startsWith('/')) return `/${currentLocale}${href}`;
return href;
};
const langMeta = {
it: { code: 'IT', label: 'Italiano', flagSrc: '/images/flags/it.svg' },
en: { code: 'EN', label: 'English', flagSrc: '/images/flags/en.svg' },
th: { code: 'TH', label: 'ไทย', flagSrc: '/images/flags/th.svg' },
} as const;
--- ---
<BaseLayout title={title} description={description}> <BaseLayout title={title} description={description}>
@ -43,15 +63,76 @@ const printSource = Astro.url.href;
class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 sm:px-6" class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 sm:px-6"
> >
<a <a
href="/" href={localize('/')}
class="text-lg font-semibold tracking-tight text-nx-fg hover:text-white sm:text-xl" class="text-lg font-semibold tracking-tight text-nx-fg hover:text-white sm:text-xl"
>NexStudio</a >NexStudio</a
> >
<div class="flex items-center gap-2">
<details class="relative" data-lang-dropdown>
<summary
class="inline-flex h-10 cursor-pointer list-none items-center gap-2 rounded-lg border border-nx-border/80 bg-nx-bg/50 px-3 text-xs font-medium text-nx-fg transition-colors hover:border-sky-500/35 hover:text-white"
aria-label="Seleziona lingua"
>
<img src={langMeta[currentLocale].flagSrc} alt="" class="h-3.5 w-5 rounded-[2px] object-cover" loading="lazy" decoding="async" />
<span>{langMeta[currentLocale].code}</span>
<svg class="h-3.5 w-3.5 text-nx-muted" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd"></path>
</svg>
</summary>
<div
class="absolute right-0 top-12 z-50 min-w-44 overflow-hidden rounded-lg border border-nx-border bg-nx-elevated/95 p-1 shadow-xl backdrop-blur"
role="menu"
>
<a
href={localeHref('it')}
class:list={[
'flex items-center justify-between rounded-md px-3 py-2 text-sm text-nx-muted transition-colors hover:bg-white/5 hover:text-nx-fg',
currentLocale === 'it' && 'bg-white/5 text-nx-fg',
]}
aria-current={currentLocale === 'it' ? 'page' : undefined}
>
<span class="flex items-center gap-2">
<img src={langMeta.it.flagSrc} alt="" class="h-3.5 w-5 rounded-[2px] object-cover" loading="lazy" decoding="async" />
{langMeta.it.label}
</span>
<span class="text-xs">{langMeta.it.code}</span>
</a>
<a
href={localeHref('en')}
class:list={[
'flex items-center justify-between rounded-md px-3 py-2 text-sm text-nx-muted transition-colors hover:bg-white/5 hover:text-nx-fg',
currentLocale === 'en' && 'bg-white/5 text-nx-fg',
]}
aria-current={currentLocale === 'en' ? 'page' : undefined}
>
<span class="flex items-center gap-2">
<img src={langMeta.en.flagSrc} alt="" class="h-3.5 w-5 rounded-[2px] object-cover" loading="lazy" decoding="async" />
{langMeta.en.label}
</span>
<span class="text-xs">{langMeta.en.code}</span>
</a>
<a
href={localeHref('th')}
class:list={[
'flex items-center justify-between rounded-md px-3 py-2 text-sm text-nx-muted transition-colors hover:bg-white/5 hover:text-nx-fg',
currentLocale === 'th' && 'bg-white/5 text-nx-fg',
]}
aria-current={currentLocale === 'th' ? 'page' : undefined}
>
<span class="flex items-center gap-2">
<img src={langMeta.th.flagSrc} alt="" class="h-3.5 w-5 rounded-[2px] object-cover" loading="lazy" decoding="async" />
{langMeta.th.label}
</span>
<span class="text-xs">{langMeta.th.code}</span>
</a>
</div>
</details>
<a <a
class="text-sm font-medium text-sky-400/90 hover:text-sky-300" class="text-sm font-medium text-sky-400/90 hover:text-sky-300"
href="/#contatti">Contattaci</a href={localize('/#contatti')}>Contattaci</a
> >
</div> </div>
</div>
</header> </header>
<main class="relative z-[1] flex-1 px-4 py-12 sm:px-6 sm:py-16"> <main class="relative z-[1] flex-1 px-4 py-12 sm:px-6 sm:py-16">
<article class="mx-auto max-w-3xl"> <article class="mx-auto max-w-3xl">
@ -86,3 +167,7 @@ const printSource = Astro.url.href;
<SiteFooter /> <SiteFooter />
</div> </div>
</BaseLayout> </BaseLayout>
<script>
import '../scripts/nx-lang-dropdown.ts';
</script>

5
src/pages/en/about.astro Normal file
View File

@ -0,0 +1,5 @@
---
import Page from '../about.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../codice-etico.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../cookies.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../dove-siamo.astro';
---
<Page />

5
src/pages/en/gdpr.astro Normal file
View File

@ -0,0 +1,5 @@
---
import Page from '../gdpr.astro';
---
<Page />

5
src/pages/en/index.astro Normal file
View File

@ -0,0 +1,5 @@
---
import Page from '../index.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../modello-organizzativo.astro';
---
<Page />

5
src/pages/en/news.astro Normal file
View File

@ -0,0 +1,5 @@
---
import Page from '../news.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../../newsletter/grazie.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../privacy.astro';
---
<Page />

5
src/pages/en/terms.astro Normal file
View File

@ -0,0 +1,5 @@
---
import Page from '../terms.astro';
---
<Page />

View File

@ -18,6 +18,24 @@ import {
} from '../data/home'; } from '../data/home';
const techSliderItems = stackSection.techSlider.items; const techSliderItems = stackSection.techSlider.items;
const path = Astro.url.pathname;
const localeMatch = path.match(/^\/(en|th)(\/|$)/);
const currentLocale = localeMatch?.[1] ?? 'it';
const localize = (href: string) => {
if (currentLocale === 'it') return href;
if (href.startsWith(`/${currentLocale}`)) return href;
if (href.startsWith('/#')) return `/${currentLocale}${href}`;
if (href.startsWith('/')) return `/${currentLocale}${href}`;
return href;
};
const langItHref = '/';
const langEnHref = '/en';
const langThHref = '/th';
const langMeta = {
it: { code: 'IT', label: 'Italiano', flagSrc: '/images/flags/it.svg' },
en: { code: 'EN', label: 'English', flagSrc: '/images/flags/en.svg' },
th: { code: 'TH', label: 'ไทย', flagSrc: '/images/flags/th.svg' },
} as const;
--- ---
<BaseLayout title={siteMeta.title} description={siteMeta.description}> <BaseLayout title={siteMeta.title} description={siteMeta.description}>
@ -37,7 +55,7 @@ const techSliderItems = stackSection.techSlider.items;
<div <div
class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 sm:px-6" class="mx-auto flex max-w-6xl items-center justify-between gap-4 px-4 py-3 sm:px-6"
> >
<a href="/" class="group no-underline"> <a href={localize('/')} class="group no-underline">
<span <span
class="text-2xl font-semibold tracking-tight text-nx-fg group-hover:text-white sm:text-3xl" class="text-2xl font-semibold tracking-tight text-nx-fg group-hover:text-white sm:text-3xl"
>{navigation.brandName}</span >{navigation.brandName}</span
@ -55,7 +73,7 @@ const techSliderItems = stackSection.techSlider.items;
return ( return (
<a <a
class="nx-nav-link" class="nx-nav-link"
href={item.href} href={localize(item.href)}
data-section={sectionId} data-section={sectionId}
data-nav-active="false" data-nav-active="false"
> >
@ -66,6 +84,65 @@ const techSliderItems = stackSection.techSlider.items;
</nav> </nav>
<div class="flex items-center gap-2 sm:gap-3"> <div class="flex items-center gap-2 sm:gap-3">
<details class="relative hidden sm:block" data-lang-dropdown>
<summary
class="inline-flex h-10 cursor-pointer list-none items-center gap-2 rounded-lg border border-nx-border/80 bg-nx-bg/50 px-3 text-xs font-medium text-nx-fg transition-colors hover:border-sky-500/35 hover:text-white"
aria-label="Seleziona lingua"
>
<img src={langMeta[currentLocale].flagSrc} alt="" class="h-3.5 w-5 rounded-[2px] object-cover" loading="lazy" decoding="async" />
<span>{langMeta[currentLocale].code}</span>
<svg class="h-3.5 w-3.5 text-nx-muted" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5.23 7.21a.75.75 0 0 1 1.06.02L10 11.168l3.71-3.938a.75.75 0 1 1 1.08 1.04l-4.25 4.5a.75.75 0 0 1-1.08 0l-4.25-4.5a.75.75 0 0 1 .02-1.06Z" clip-rule="evenodd"></path>
</svg>
</summary>
<div
class="absolute right-0 top-12 z-50 min-w-44 overflow-hidden rounded-lg border border-nx-border bg-nx-elevated/95 p-1 shadow-xl backdrop-blur"
role="menu"
>
<a
href={langItHref}
class:list={[
'flex items-center justify-between rounded-md px-3 py-2 text-sm text-nx-muted transition-colors hover:bg-white/5 hover:text-nx-fg',
currentLocale === 'it' && 'bg-white/5 text-nx-fg',
]}
aria-current={currentLocale === 'it' ? 'page' : undefined}
>
<span class="flex items-center gap-2">
<img src={langMeta.it.flagSrc} alt="" class="h-3.5 w-5 rounded-[2px] object-cover" loading="lazy" decoding="async" />
{langMeta.it.label}
</span>
<span class="text-xs">{langMeta.it.code}</span>
</a>
<a
href={langEnHref}
class:list={[
'flex items-center justify-between rounded-md px-3 py-2 text-sm text-nx-muted transition-colors hover:bg-white/5 hover:text-nx-fg',
currentLocale === 'en' && 'bg-white/5 text-nx-fg',
]}
aria-current={currentLocale === 'en' ? 'page' : undefined}
>
<span class="flex items-center gap-2">
<img src={langMeta.en.flagSrc} alt="" class="h-3.5 w-5 rounded-[2px] object-cover" loading="lazy" decoding="async" />
{langMeta.en.label}
</span>
<span class="text-xs">{langMeta.en.code}</span>
</a>
<a
href={langThHref}
class:list={[
'flex items-center justify-between rounded-md px-3 py-2 text-sm text-nx-muted transition-colors hover:bg-white/5 hover:text-nx-fg',
currentLocale === 'th' && 'bg-white/5 text-nx-fg',
]}
aria-current={currentLocale === 'th' ? 'page' : undefined}
>
<span class="flex items-center gap-2">
<img src={langMeta.th.flagSrc} alt="" class="h-3.5 w-5 rounded-[2px] object-cover" loading="lazy" decoding="async" />
{langMeta.th.label}
</span>
<span class="text-xs">{langMeta.th.code}</span>
</a>
</div>
</details>
<button <button
type="button" type="button"
data-nx-chat-open data-nx-chat-open
@ -122,7 +199,7 @@ const techSliderItems = stackSection.techSlider.items;
return ( return (
<a <a
class="nx-nav-link block py-1" class="nx-nav-link block py-1"
href={item.href} href={localize(item.href)}
data-section={sectionId} data-section={sectionId}
data-nav-active="false" data-nav-active="false"
@click="mobile = false" @click="mobile = false"
@ -663,5 +740,6 @@ const techSliderItems = stackSection.techSlider.items;
</div> </div>
<script> <script>
import '../scripts/nx-nav-spy.ts'; import '../scripts/nx-nav-spy.ts';
import '../scripts/nx-lang-dropdown.ts';
</script> </script>
</BaseLayout> </BaseLayout>

5
src/pages/th/about.astro Normal file
View File

@ -0,0 +1,5 @@
---
import Page from '../about.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../codice-etico.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../cookies.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../dove-siamo.astro';
---
<Page />

5
src/pages/th/gdpr.astro Normal file
View File

@ -0,0 +1,5 @@
---
import Page from '../gdpr.astro';
---
<Page />

5
src/pages/th/index.astro Normal file
View File

@ -0,0 +1,5 @@
---
import Page from '../index.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../modello-organizzativo.astro';
---
<Page />

5
src/pages/th/news.astro Normal file
View File

@ -0,0 +1,5 @@
---
import Page from '../news.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../../newsletter/grazie.astro';
---
<Page />

View File

@ -0,0 +1,5 @@
---
import Page from '../privacy.astro';
---
<Page />

5
src/pages/th/terms.astro Normal file
View File

@ -0,0 +1,5 @@
---
import Page from '../terms.astro';
---
<Page />

View File

@ -2,6 +2,7 @@
interface ContactFormCfg { interface ContactFormCfg {
actionUrl: string; actionUrl: string;
mailto: string; mailto: string;
turnstileSiteKey: string;
notifySubject: string; notifySubject: string;
successRedirect: string; successRedirect: string;
fieldName: string; fieldName: string;
@ -18,6 +19,7 @@ function readCfg(): ContactFormCfg {
return { return {
actionUrl: '', actionUrl: '',
mailto: '', mailto: '',
turnstileSiteKey: '',
notifySubject: '', notifySubject: '',
successRedirect: '', successRedirect: '',
fieldName: 'name', fieldName: 'name',
@ -39,6 +41,23 @@ function bind(): void {
const err = document.getElementById('nx-contact-form-error'); const err = document.getElementById('nx-contact-form-error');
if (err) err.classList.add('hidden'); if (err) err.classList.add('hidden');
const hasTurnstile =
typeof cfg.turnstileSiteKey === 'string' && cfg.turnstileSiteKey.length > 0;
if (cfg.actionUrl && hasTurnstile) {
const token = form.querySelector<HTMLInputElement>(
'input[name="cf-turnstile-response"]',
)?.value;
if (!token) {
e.preventDefault();
if (err) {
err.textContent =
'Completa il controllo anti-spam prima di inviare il messaggio.';
err.classList.remove('hidden');
}
return;
}
}
if (cfg.actionUrl) { if (cfg.actionUrl) {
return; return;
} }

View File

@ -0,0 +1,32 @@
function bindLanguageDropdown(): void {
const dropdowns = Array.from(
document.querySelectorAll<HTMLDetailsElement>('details[data-lang-dropdown]'),
);
if (!dropdowns.length) return;
document.addEventListener('click', (event) => {
const target = event.target as Node | null;
dropdowns.forEach((dd) => {
if (!dd.open) return;
if (target && dd.contains(target)) return;
dd.open = false;
});
});
document.addEventListener('keydown', (event) => {
if (event.key !== 'Escape') return;
dropdowns.forEach((dd) => {
dd.open = false;
});
});
}
if (typeof document !== 'undefined') {
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', bindLanguageDropdown, {
once: true,
});
} else {
bindLanguageDropdown();
}
}