diff --git a/src/components/language-selector2.test.tsx b/src/components/language-selector2.test.tsx new file mode 100644 index 000000000..f2b04d92a --- /dev/null +++ b/src/components/language-selector2.test.tsx @@ -0,0 +1,77 @@ +import { fireEvent, render, within } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import LanguageSelector2, { createLanguagePath } from './language-selector2'; + +const localeState = vi.hoisted(() => ({ + current: 'ko', +})); + +vi.mock('@/lib/use-locale', () => ({ + default: () => localeState.current, +})); + +describe('LanguageSelector2', () => { + beforeEach(() => { + localeState.current = 'ko'; + }); + + it('renders the current language as a dropdown trigger before opening the menu', () => { + const container = document.createElement('div'); + const result = render(, { + baseElement: container, + container, + }); + + const trigger = within(result.container).getByRole('button', { name: /한국어/ }); + + expect(trigger).toHaveAttribute('aria-haspopup', 'menu'); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + expect(within(result.container).queryByRole('menu')).not.toBeInTheDocument(); + }); + + it('opens the language menu and marks the current locale', () => { + const container = document.createElement('div'); + const result = render(, { + baseElement: container, + container, + }); + + fireEvent.click(within(result.container).getByRole('button', { name: /한국어/ })); + + expect(within(result.container).getByRole('menu', { name: 'Language' })).toBeTruthy(); + expect(within(result.container).getByRole('menuitemradio', { name: '한국어' })).toHaveAttribute( + 'aria-checked', + 'true', + ); + expect(within(result.container).getByRole('menuitemradio', { name: 'English' })).toHaveAttribute( + 'aria-checked', + 'false', + ); + expect(within(result.container).getByRole('menuitemradio', { name: '日本語' })).toHaveAttribute( + 'aria-checked', + 'false', + ); + }); + + it('closes the menu with Escape', () => { + const container = document.createElement('div'); + const result = render(, { + baseElement: container, + container, + }); + + fireEvent.click(within(result.container).getByRole('button', { name: /한국어/ })); + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(within(result.container).queryByRole('menu')).not.toBeInTheDocument(); + }); +}); + +describe('createLanguagePath', () => { + it('replaces the current locale segment while preserving the rest of the path', () => { + expect(createLanguagePath('ja', 'en', '/en/user-manual/database-access-control')).toBe( + '/ja/user-manual/database-access-control', + ); + }); +}); diff --git a/src/components/language-selector2.tsx b/src/components/language-selector2.tsx index fb32ddeab..e913a2560 100644 --- a/src/components/language-selector2.tsx +++ b/src/components/language-selector2.tsx @@ -1,6 +1,7 @@ 'use client'; -import React from 'react'; +import React, { useEffect, useId, useMemo, useRef, useState } from 'react'; +import { CheckIcon, ChevronDownIcon, LanguageIcon } from '@heroicons/react/24/outline'; import { addBasePath } from 'next/dist/client/add-base-path'; import useLocale from '@/lib/use-locale'; @@ -20,14 +21,18 @@ export const languages: LanguageOption[] = [ // Constants const ONE_YEAR = 365 * 24 * 60 * 60 * 1000; +export const createLanguagePath = (lang: string, currentLang: string, pathname: string): string => { + return pathname.replace(`/${currentLang}`, `/${lang}`); +}; + // Language change handler with cookie support export const handleLanguageChange = (lang: string, currentLang: string, pathname: string): void => { // Set cookie for language preference const date = new Date(Date.now() + ONE_YEAR); document.cookie = `NEXT_LOCALE=${lang}; expires=${date.toUTCString()}; path=/`; - + // Navigate to the new language URL - const newPath = pathname.replace(`/${currentLang}`, `/${lang}`); + const newPath = createLanguagePath(lang, currentLang, pathname); location.href = addBasePath(newPath); }; @@ -37,6 +42,7 @@ export const languageSelectorStyles = ` padding: 0px 0px 16px 0px; border-bottom: 1px solid #e5e7eb; margin-bottom: 16px; + position: relative; } .dark .language-selector-toc { @@ -57,72 +63,185 @@ export const languageSelectorStyles = ` color: #d1d5db; } - .globe-icon { + .language-selector-trigger { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + width: min(100%, 11rem); + min-height: 38px; + padding: 8px 10px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + color: #111827; + font-size: 14px; + font-weight: 500; + box-sizing: border-box; + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease; + } + + .dark .language-selector-trigger { + background: #111827; + border-color: #4b5563; + color: #f9fafb; + } + + .language-selector-trigger:hover, + .language-selector-trigger[aria-expanded="true"] { + border-color: #6b7280; + background: #f9fafb; + } + + .dark .language-selector-trigger:hover, + .dark .language-selector-trigger[aria-expanded="true"] { + border-color: #9ca3af; + background: #1f2937; + } + + .language-selector-trigger-main, + .language-option-main { + display: flex; + align-items: center; + min-width: 0; + gap: 8px; + } + + .language-selector-icon, + .language-selector-chevron, + .language-option-check { + flex: 0 0 auto; + width: 16px; + height: 16px; color: #6b7280; } - .dark .globe-icon { + .dark .language-selector-icon, + .dark .language-selector-chevron, + .dark .language-option-check { color: #9ca3af; } - .language-buttons { + .language-selector-chevron { + transition: transform 0.15s ease; + } + + .language-selector-trigger[aria-expanded="true"] .language-selector-chevron { + transform: rotate(180deg); + } + + .language-selector-current, + .language-option-name { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + .language-selector-menu { + position: absolute; + z-index: 30; + top: calc(100% - 10px); + left: 0; display: flex; flex-direction: column; - gap: 8px; - width:10em; + width: min(100%, 11rem); + padding: 4px; + border: 1px solid #d1d5db; + border-radius: 6px; + background: #fff; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.14); + } + + .dark .language-selector-menu { + border-color: #4b5563; + background: #111827; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.4); } - .language-button { + .language-option { display: flex; align-items: center; + justify-content: space-between; gap: 8px; - padding: 10px 12px; - text-decoration: none; - border-radius: 6px; - font-size: 14px; - font-weight: 500; - transition: all 0.2s ease; width: 100%; - box-sizing: border-box; + min-height: 34px; + padding: 7px 8px; border: none; + border-radius: 4px; + background: transparent; + color: #374151; cursor: pointer; - background: none; - } - - .language-button:disabled { - cursor: default; - } - - .language-button.active { - border: 1px solid #000; + font-size: 14px; + font-weight: 500; + text-align: left; } - .dark .language-button.active { - border: 1px solid #fff; + .dark .language-option { + color: #d1d5db; } - .language-button.inactive { - color: #495057; + .language-option:hover, + .language-option:focus-visible { + background: #f3f4f6; + outline: none; } - .dark .language-button.inactive { - color: #d1d5db; + .dark .language-option:hover, + .dark .language-option:focus-visible { + background: #1f2937; } - .language-button.inactive:hover:not(:disabled) { - background: #e9ecef; + .language-option[aria-checked="true"] { + color: #111827; } - .dark .language-button.inactive:hover:not(:disabled) { - background: #4b5563; + .dark .language-option[aria-checked="true"] { + color: #f9fafb; } `; // Main component export default function LanguageSelector2() { + const [isOpen, setIsOpen] = useState(false); + const menuId = useId(); + const rootRef = useRef(null); const currentLang = useLocale('en'); - const handleClick = (lang: string, e: React.MouseEvent) => { - e.preventDefault(); + const currentLanguage = useMemo( + () => languages.find(language => language.code === currentLang) ?? languages[0], + [currentLang], + ); + + useEffect(() => { + if (!isOpen) { + return; + } + + const handlePointerDown = (event: MouseEvent) => { + if (!rootRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + const handleEscape = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handlePointerDown); + document.addEventListener('keydown', handleEscape); + + return () => { + document.removeEventListener('mousedown', handlePointerDown); + document.removeEventListener('keydown', handleEscape); + }; + }, [isOpen]); + + const handleSelect = (lang: string) => { + setIsOpen(false); + if (lang !== currentLang) { handleLanguageChange(lang, currentLang, window.location.pathname); } @@ -131,29 +250,55 @@ export default function LanguageSelector2() { return ( <> - + - 🌐 + Language - - {languages.map((language) => { - const isActive = language.code === currentLang; - return ( - handleClick(language.code, e)} - > - {language.flag} - {language.name} - - ); - })} - + setIsOpen(open => !open)} + onKeyDown={event => { + if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setIsOpen(true); + } + }} + > + + {currentLanguage.flag} + {currentLanguage.name} + + + + {isOpen && ( + + {languages.map(language => { + const isActive = language.code === currentLang; + return ( + handleSelect(language.code)} + > + + {language.flag} + {language.name} + + {isActive && } + + ); + })} + + )} > ); } -