Files
365devnet/src/components/LanguageDropdown.astro
Richard Bergsma 83a11a45fb Update LanguageDropdown and Header components for improved localization and layout
- Enhanced the LanguageDropdown component by updating language names to their native forms and adding non-breaking spaces for better readability.
- Refactored the Header component to improve layout, including a gap between icons and adjusted class attributes for better spacing.
- Ensured consistent filtering of navigation links and improved accessibility for the contact link.
2025-07-24 14:49:12 +02:00

399 lines
14 KiB
Plaintext

---
import { Icon } from 'astro-icon/components';
import { supportedLanguages } from '~/i18n/translations';
interface Props {
currentLang: string;
}
const { currentLang } = Astro.props;
type SupportedLanguage = (typeof supportedLanguages)[number];
const languages = [
{ code: 'en' as SupportedLanguage, name: 'English\u00A0\u00A0', flag: 'gb' },
{ code: 'nl' as SupportedLanguage, name: 'Nederlands', flag: 'nl' },
{ code: 'de' as SupportedLanguage, name: 'Deutsch', flag: 'de' },
{ code: 'fr' as SupportedLanguage, name: 'Français', flag: 'fr' },
].filter((lang) => supportedLanguages.includes(lang.code));
const currentLanguage = languages.find((lang) => lang.code === currentLang) || languages[0];
---
<div class="relative inline-block text-left language-dropdown">
<div>
<button
type="button"
class="inline-flex justify-center w-full rounded-md border border-gray-300 dark:border-gray-600 shadow-sm px-4 py-2 bg-white dark:bg-gray-800 text-sm font-medium text-gray-700 dark:text-gray-200 hover:bg-gray-50 dark:hover:bg-gray-700 focus:outline-none focus:ring-2 focus:ring-offset-2 dark:focus:ring-offset-gray-800 focus:ring-indigo-500 dark:focus:ring-indigo-400 focus-visible:ring-4 transition-colors duration-200 dropdown-button"
id="menu-button"
aria-expanded="false"
aria-haspopup="true"
aria-label={`Select language. Current language: ${currentLanguage.name}`}
>
<Icon name={`circle-flags:${currentLanguage.flag}`} class="inline-block w-5 h-5 mr-2" />
<span id="selected-language">{currentLanguage.name}</span>
<Icon
name="tabler:chevron-down"
class="ml-2 -mr-1 h-5 w-5 transition-transform duration-200"
aria-hidden="true"
id="chevron-icon"
/>
</button>
</div>
<div
class="absolute left-0 rounded-md shadow-lg bg-white dark:bg-gray-800 ring-1 ring-black ring-opacity-5 dark:ring-gray-600 focus:outline-none transform opacity-0 scale-95 transition-all duration-200 max-h-[300px] overflow-y-auto w-full language-menu"
role="menu"
aria-orientation="vertical"
aria-labelledby="menu-button"
tabindex="-1"
id="language-menu"
style="max-height: min(300px, 70vh);"
>
<div class="py-1" role="none">
{
languages.map((lang) => (
<button
type="button"
data-lang-code={lang.code}
class="text-gray-700 dark:text-gray-300 block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white transition-colors duration-200"
role="menuitem"
tabindex="-1"
aria-label={`Switch to ${lang.name} language`}
>
<Icon name={`circle-flags:${lang.flag}`} class="inline-block w-5 h-5 mr-2" />
{lang.name}
</button>
))
}
</div>
</div>
<select
id="language-select"
class="block w-full text-left px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 hover:text-gray-900 dark:hover:text-white focus:bg-gray-100 dark:focus:bg-gray-700 focus:text-gray-900 dark:focus:text-white focus:outline-none focus:ring-2 focus:ring-indigo-500 dark:focus:ring-indigo-400 transition-colors duration-200 rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-200 language-select"
>
{
languages.map((lang) => (
<option value={lang.code} selected={lang.code === currentLang}>
{lang.name}
</option>
))
}
</select>
</div>
<style>
.language-dropdown {
@apply md:inline-block md:relative;
}
.language-select {
display: none;
}
@media (max-width: 767px) {
.language-dropdown {
display: inline-block;
}
/* Keep the native select hidden even on mobile */
.language-select {
display: none;
}
}
.language-menu.open-downward {
top: 100%;
margin-top: 0.5rem;
transform-origin: top;
}
.language-menu.open-upward {
bottom: 100%;
margin-bottom: 0.5rem;
transform-origin: bottom;
}
.language-menu:not(.hidden).open-downward {
animation: slideDown 0.2s ease-out forwards;
}
.language-menu:not(.hidden).open-upward {
animation: slideUp 0.2s ease-out forwards;
}
@keyframes slideDown {
from {
opacity: 0;
transform: scale(0.95) translateY(-0.5rem);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes slideUp {
from {
opacity: 0;
transform: scale(0.95) translateY(0.5rem);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
</style>
<script define:vars={{ supportedLanguages }}>
function setupLanguageDropdown() {
const button = document.querySelector('#menu-button');
const menu = document.querySelector('#language-menu');
const chevronIcon = document.querySelector('#chevron-icon');
const selectedLanguageText = document.querySelector('#selected-language');
const languageButtons = document.querySelectorAll('[data-lang-code]');
const languageSelect = document.querySelector('#language-select');
if (!button || !menu || !chevronIcon || !selectedLanguageText || !languageSelect) {
return;
}
let isOpen = false;
function closeMenu() {
if (menu && button && chevronIcon) {
menu.classList.add('hidden');
button.setAttribute('aria-expanded', 'false');
chevronIcon.style.transform = 'rotate(0deg)';
isOpen = false;
}
}
function openMenu() {
if (menu && button && chevronIcon) {
// First show the menu to calculate its height
menu.classList.remove('hidden');
menu.classList.remove('open-upward', 'open-downward');
// Calculate available space
const buttonRect = button.getBoundingClientRect();
const menuRect = menu.getBoundingClientRect();
const viewportHeight = window.innerHeight;
const spaceBelow = viewportHeight - buttonRect.bottom;
const spaceAbove = buttonRect.top;
const menuHeight = Math.min(menuRect.height, 300); // Cap at 300px
// Determine if menu should open upward
const shouldOpenUpward = spaceBelow < menuHeight && spaceAbove > spaceBelow;
// Position menu
menu.style.maxHeight = `${Math.min(shouldOpenUpward ? spaceAbove - 8 : spaceBelow - 8, 300)}px`;
if (shouldOpenUpward) {
menu.classList.add('open-upward');
chevronIcon.style.transform = 'rotate(180deg)';
} else {
menu.classList.add('open-downward');
chevronIcon.style.transform = 'rotate(0deg)';
}
button.setAttribute('aria-expanded', 'true');
isOpen = true;
}
}
// Initialize closed state
closeMenu();
// Toggle menu
button.addEventListener('click', (e) => {
e.stopPropagation();
if (isOpen) {
closeMenu();
} else {
openMenu();
// Don't automatically focus any menu item to avoid default highlighting
}
});
// Handle language selection
languageButtons.forEach((langButton) => {
langButton.addEventListener('click', () => {
const langCode = langButton.dataset.langCode;
if (!langCode) return;
// Update button text and icon
const langName = langButton.textContent ? langButton.textContent.trim() : '';
const flagIcon = langButton.querySelector('svg');
if (langName && flagIcon) {
selectedLanguageText.textContent = langName;
const currentFlag = button.querySelector('svg:first-child');
if (currentFlag) {
currentFlag.replaceWith(flagIcon.cloneNode(true));
}
}
// Close menu
closeMenu();
// Get current URL information
const currentUrl = new URL(window.location.href);
const currentPath = currentUrl.pathname.replace(/\/$/, '');
const currentHash = currentUrl.hash;
const pathSegments = currentPath.split('/').filter(Boolean);
// Check if we're on a language-specific path
const isLangPath = supportedLanguages.includes(pathSegments[0]);
// Get the previous language code
const previousLangCode = isLangPath ? pathSegments[0] : 'en';
// Extract the page path without language
let pagePath = '';
if (isLangPath && pathSegments.length > 1) {
// If we're on a language-specific path, get everything after the language code
pagePath = `/${pathSegments.slice(1).join('/')}`;
} else if (!isLangPath && pathSegments.length > 0) {
// If we're not on a language-specific path, use the current path
pagePath = `/${pathSegments.join('/')}`;
}
// Handle special case for root path
const isRootPath = pathSegments.length === 0 || (isLangPath && pathSegments.length === 1);
// Construct the new URL
let newUrl = isRootPath ? `/${langCode}` : `/${langCode}${pagePath}`;
// Clean up any potential double slashes
newUrl = newUrl.replace(/\/+/g, '/');
// Append hash fragment if it exists
if (currentHash) {
newUrl += currentHash;
}
// Store the language preference in localStorage and cookies
if (window.languageUtils) {
window.languageUtils.storeLanguagePreference(langCode);
} else {
// Fallback if languageUtils is not available
localStorage.setItem('preferredLanguage', langCode);
// Also set a cookie for server-side detection
const expirationDate = new Date();
expirationDate.setFullYear(expirationDate.getFullYear() + 1);
document.cookie = `preferredLanguage=${langCode}; expires=${expirationDate.toUTCString()}; path=/; SameSite=Lax`;
}
// Dispatch the language changed event
const reloadEvent = new CustomEvent('languageChanged', {
detail: {
langCode,
previousLangCode,
path: newUrl,
willReload: true,
},
});
document.dispatchEvent(reloadEvent);
// Construct the full URL
const newFullUrl = `${window.location.origin}${newUrl}`;
// Force a complete page reload to ensure all content is updated to the new language
// This bypasses any client-side caching and ensures a fresh server render
window.location.href = newFullUrl + '?t=' + Date.now();
});
});
// Handle language selection from select element
languageSelect.addEventListener('change', (event) => {
const langCode = event.target.value;
if (!langCode) return;
// Get current URL information
const currentUrl = new URL(window.location.href);
const currentPath = currentUrl.pathname.replace(/\/$/, '');
const currentHash = currentUrl.hash;
const pathSegments = currentPath.split('/').filter(Boolean);
// Check if we're on a language-specific path
const isLangPath = supportedLanguages.includes(pathSegments[0]);
// Get the previous language code
const previousLangCode = isLangPath ? pathSegments[0] : 'en';
// Extract the page path without language
let pagePath = '';
if (isLangPath && pathSegments.length > 1) {
// If we're on a language-specific path, get everything after the language code
pagePath = `/${pathSegments.slice(1).join('/')}`;
} else if (!isLangPath && pathSegments.length > 0) {
// If we're not on a language-specific path, use the current path
pagePath = `/${pathSegments.join('/')}`;
}
// Handle special case for root path
const isRootPath = pathSegments.length === 0 || (isLangPath && pathSegments.length === 1);
// Construct the new URL
let newUrl = isRootPath ? `/${langCode}` : `/${langCode}${pagePath}`;
// Clean up any potential double slashes
newUrl = newUrl.replace(/\/+/g, '/');
// Append hash fragment if it exists
if (currentHash) {
newUrl += currentHash;
}
// Store the language preference in localStorage and cookies
if (window.languageUtils) {
window.languageUtils.storeLanguagePreference(langCode);
} else {
// Fallback if languageUtils is not available
localStorage.setItem('preferredLanguage', langCode);
// Also set a cookie for server-side detection
const expirationDate = new Date();
expirationDate.setFullYear(expirationDate.getFullYear() + 1);
document.cookie = `preferredLanguage=${langCode}; expires=${expirationDate.toUTCString()}; path=/; SameSite=Lax`;
}
// Dispatch the language changed event
const reloadEvent = new CustomEvent('languageChanged', {
detail: {
langCode,
previousLangCode,
path: newUrl,
willReload: true,
},
});
document.dispatchEvent(reloadEvent);
// Construct the full URL
const newFullUrl = `${window.location.origin}${newUrl}`;
// Force a complete page reload to ensure all content is updated to the new language
// This bypasses any client-side caching and ensures a fresh server render
window.location.href = newFullUrl + '?t=' + Date.now();
});
}
// Run setup when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupLanguageDropdown);
} else {
setupLanguageDropdown();
}
// Re-run setup when the page content is updated (e.g., after navigation)
document.addEventListener('astro:page-load', setupLanguageDropdown);
// Listen for popstate events (browser back/forward buttons)
window.addEventListener('popstate', (_event) => {
// No need to manually update anything here as the browser will
// automatically load the correct URL, and Astro will handle the rendering
});
</script>