From 2dea7c0852ebb0eb7be75602defb999b86e8e8d7 Mon Sep 17 00:00:00 2001 From: "yanqi.zong" Date: Wed, 7 May 2025 11:19:47 -0700 Subject: [PATCH 1/2] feat: update I18nToggle --- apps/example/src/routes/__root.tsx | 26 +- packages/ui/src/__tests__/I18nToggle.test.tsx | 289 ++++++++++++++++-- packages/ui/src/__tests__/i18n.test.ts | 177 +++++++++++ packages/ui/src/components/I18nToggle.tsx | 82 ++++- packages/ui/src/index.ts | 1 + packages/ui/src/utils/i18n.ts | 65 ++++ 6 files changed, 609 insertions(+), 31 deletions(-) create mode 100644 packages/ui/src/__tests__/i18n.test.ts create mode 100644 packages/ui/src/utils/i18n.ts diff --git a/apps/example/src/routes/__root.tsx b/apps/example/src/routes/__root.tsx index e0c35b6..b10ebd2 100644 --- a/apps/example/src/routes/__root.tsx +++ b/apps/example/src/routes/__root.tsx @@ -3,6 +3,7 @@ import { Outlet, Scripts, createRootRoute, + useLocation, } from '@tanstack/react-router'; import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; import React from 'react'; @@ -19,13 +20,14 @@ import logoColor100w from '~/images/logo-color-100w.png'; import { TbBrandBluesky, TbBrandTwitter } from 'react-icons/tb'; import { SearchButton } from '~/components/SearchButton'; -import { I18nToggle } from '@tanstack-dev/components'; +import { I18nToggle, getI18nLinks } from '@tanstack-dev/components'; import { BiSolidCheckShield } from 'react-icons/bi'; import { MdSupport, MdLibraryBooks, MdLineAxis } from 'react-icons/md'; import { ThemeToggle } from '~/components/ThemeToggle'; export const Route = createRootRoute({ - head: () => ({ + loader: ({ location }) => ({ href: location.href }), + head: (ctx) => ({ meta: [ { charSet: 'utf-8', @@ -42,6 +44,7 @@ export const Route = createRootRoute({ ], links: [ { rel: 'stylesheet', href: appCss }, + { rel: 'stylesheet', href: carbonStyles, @@ -66,11 +69,18 @@ export const Route = createRootRoute({ { rel: 'manifest', href: '/site.webmanifest', color: '#fffff' }, { rel: 'icon', href: '/favicon.ico' }, { rel: 'preconnect', href: 'https://fonts.googleapis.com' }, - { rel: 'preconnect', href: 'https://fonts.gstatic.com', crossOrigin: '' }, + { + rel: 'preconnect', + href: 'https://fonts.gstatic.com', + crossOrigin: '', + }, { rel: 'stylesheet', href: 'https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap', }, + ...getI18nLinks({ + href: ctx.loaderData.href, + }), ], scripts: [], }), @@ -81,6 +91,10 @@ function RootDocument() { const detailsRef = React.useRef(null!); const linkClasses = `flex items-center justify-between group px-2 py-1 rounded-lg hover:bg-gray-500 hover:bg-opacity-10 font-black`; + const href = useLocation({ + select: (location) => location.href, + }); + const items = ( <> {libraries.map((library, i) => { @@ -285,7 +299,11 @@ function RootDocument() { > - +
diff --git a/packages/ui/src/__tests__/I18nToggle.test.tsx b/packages/ui/src/__tests__/I18nToggle.test.tsx index b4f84d1..daa9b37 100644 --- a/packages/ui/src/__tests__/I18nToggle.test.tsx +++ b/packages/ui/src/__tests__/I18nToggle.test.tsx @@ -1,30 +1,283 @@ -import { render, screen, cleanup } from '@testing-library/react'; -import { describe, it, expect, afterEach } from 'vitest'; +import { + render, + screen, + fireEvent, + cleanup, + act, +} from '@testing-library/react'; +import { describe, it, expect, afterEach, vi } from 'vitest'; import { I18nToggle } from '../index'; +import { LanguageOption } from '../utils/i18n'; describe('I18nToggle Component', () => { + // Helper functions + const openDropdown = (container: HTMLElement) => { + const button = container.querySelector('[aria-label="Change language"]'); + if (button) { + fireEvent.click(button); + } else { + throw new Error('Button not found'); + } + }; + + const renderComponent = (props = {}) => { + cleanup(); // Ensure DOM is clean before each render + return render(); + }; + afterEach(() => { - cleanup(); + cleanup(); // Cleanup after each test + vi.clearAllMocks(); + }); + + describe('Rendering', () => { + it('renders with correct aria-label', () => { + renderComponent(); + const button = screen.getByRole('button', { name: /change language/i }); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-label', 'Change language'); + }); + + it('applies custom className when provided', () => { + const customClass = 'test-class'; + renderComponent({ className: customClass }); + const button = screen.getByRole('button', { name: /change language/i }); + expect(button).toHaveClass(customClass); + }); + + it('forwards additional props to the button', () => { + renderComponent({ 'data-testid': 'test-id' }); + const button = screen.getByTestId('test-id'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-label', 'Change language'); + }); }); - it('renders with correct aria-label', () => { - render(); - const button = screen.getByRole('button', { name: /change language/i }); - expect(button).toBeInTheDocument(); - expect(button).toHaveAttribute('aria-label', 'Change language'); + describe('Dropdown behavior', () => { + it('keeps dropdown hidden initially', () => { + const { container } = renderComponent(); + // Check for the CSS class 'hidden' instead of checking for absence in the DOM + const dropdown = container.querySelector('.absolute'); + expect(dropdown).toHaveClass('hidden'); + }); + + it('opens dropdown menu when clicked', () => { + const { container } = renderComponent(); + openDropdown(container); + + // The menu should be in the document and visible (not having 'hidden' class) + const menu = screen.getByRole('menu'); + const dropdown = container.querySelector('.absolute'); + expect(dropdown).not.toHaveClass('hidden'); + expect(menu).toBeInTheDocument(); + expect(screen.getByText('English')).toBeInTheDocument(); + expect(screen.getByText('简体中文')).toBeInTheDocument(); + }); + + it('closes dropdown when a language option is clicked', () => { + const { container } = renderComponent(); + openDropdown(container); + + const englishLink = screen.getByText('English'); + fireEvent.click(englishLink); + + // Check that the dropdown has the 'hidden' class + const dropdown = container.querySelector('.absolute'); + expect(dropdown).toHaveClass('hidden'); + }); + + it('closes dropdown when clicking outside', async () => { + // Create a div to serve as the outside element + const outsideDiv = document.createElement('div'); + outsideDiv.setAttribute('data-testid', 'outside-element'); + document.body.appendChild(outsideDiv); + + // Render the component + const { container } = renderComponent(); + + // Open the dropdown + openDropdown(container); + + // Verify dropdown is open + const dropdown = container.querySelector('.absolute'); + expect(dropdown).not.toHaveClass('hidden'); + + // Simulate a mousedown event outside the dropdown + act(() => { + const mouseDownEvent = new MouseEvent('mousedown', { + bubbles: true, + cancelable: true, + }); + outsideDiv.dispatchEvent(mouseDownEvent); + }); + + // Wait for state to update - even though React Testing Library usually doesn't need this, + // sometimes with event handlers there can be timing issues + await new Promise((resolve) => setTimeout(resolve, 0)); + + // Check that the dropdown has the 'hidden' class now + expect(dropdown).toHaveClass('hidden'); + + // Clean up + document.body.removeChild(outsideDiv); + }); }); - it('applies custom className when provided', () => { - const customClass = 'test-class'; - render(); - const button = screen.getByRole('button', { name: /change language/i }); - expect(button).toHaveClass(customClass); + describe('Language options', () => { + it('renders with custom language options when provided', () => { + const customOptions: LanguageOption[] = [ + { + label: 'Français', + domain: 'https://fr.tanstack.com', + langCode: 'fr', + }, + { label: 'Español', domain: 'https://es.tanstack.com', langCode: 'es' }, + ]; + + const { container } = renderComponent({ languageOptions: customOptions }); + openDropdown(container); + + expect(screen.getByText('Français')).toBeInTheDocument(); + expect(screen.getByText('Español')).toBeInTheDocument(); + expect(screen.queryByText('English')).not.toBeInTheDocument(); + expect(screen.queryByText('简体中文')).not.toBeInTheDocument(); + }); }); - it('forwards additional props to the button', () => { - render(); - const button = screen.getByTestId('test-id'); - expect(button).toBeInTheDocument(); - expect(button).toHaveAttribute('aria-label', 'Change language'); + describe('URL handling', () => { + it('constructs correct URLs with custom language options and href prop', () => { + const customOptions: LanguageOption[] = [ + { label: 'Deutsch', domain: 'https://de.tanstack.com', langCode: 'de' }, + ]; + const testHref = 'https://tanstack.com/some/path'; + + const { container } = renderComponent({ + href: testHref, + languageOptions: customOptions, + }); + openDropdown(container); + + const germanLink = screen.getByText('Deutsch'); + expect(germanLink).toHaveAttribute( + 'href', + 'https://de.tanstack.com/some/path' + ); + }); + + it('constructs correct URLs with full href prop', () => { + const testHref = 'https://tanstack.com/query/latest/docs/react/overview'; + const { container } = renderComponent({ href: testHref }); + openDropdown(container); + + const englishLink = screen.getByText('English'); + const chineseLink = screen.getByText('简体中文'); + + expect(englishLink).toHaveAttribute( + 'href', + 'https://tanstack.com/query/latest/docs/react/overview' + ); + expect(chineseLink).toHaveAttribute( + 'href', + 'https://zh-hans.tanstack.dev/query/latest/docs/react/overview' + ); + }); + + it('constructs correct URLs when href is a path', () => { + const testHref = '/query/latest/docs/react/overview'; + const { container } = renderComponent({ href: testHref }); + openDropdown(container); + + const englishLink = screen.getByText('English'); + const chineseLink = screen.getByText('简体中文'); + + expect(englishLink).toHaveAttribute( + 'href', + 'https://tanstack.com/query/latest/docs/react/overview' + ); + expect(chineseLink).toHaveAttribute( + 'href', + 'https://zh-hans.tanstack.dev/query/latest/docs/react/overview' + ); + }); + + it('handles href with query parameters and hash correctly', () => { + const testHref = '/some/path?query=param#hash'; + const { container } = renderComponent({ href: testHref }); + openDropdown(container); + + const englishLink = screen.getByText('English'); + expect(englishLink).toHaveAttribute( + 'href', + 'https://tanstack.com/some/path?query=param#hash' + ); + }); + + it('handles href that is just a slash (root path)', () => { + const testHref = '/'; + const { container } = renderComponent({ href: testHref }); + openDropdown(container); + + const englishLink = screen.getByText('English'); + expect(englishLink).toHaveAttribute('href', 'https://tanstack.com/'); + }); + }); + + describe('Active language highlighting', () => { + it('highlights the active language correctly based on href domain', () => { + const { container } = renderComponent({ + href: 'https://zh-hans.tanstack.dev/some/page', + currentLanguage: 'zh-Hans', + }); + openDropdown(container); + + const englishLink = screen.getByText('English').closest('a'); + const chineseLink = screen.getByText('简体中文').closest('a'); + + expect(englishLink).not.toHaveClass('bg-gray-100'); + expect(chineseLink).toHaveClass('bg-gray-100'); + expect(chineseLink).toHaveClass('text-gray-900'); + }); + + it('highlights the active language correctly with custom options', () => { + const customOptions: LanguageOption[] = [ + { label: '日本語', domain: 'https://ja.tanstack.com', langCode: 'ja' }, + { label: '한국어', domain: 'https://ko.tanstack.com', langCode: 'ko' }, + ]; + + const { container } = renderComponent({ + href: 'https://ko.tanstack.com/another/path', + languageOptions: customOptions, + currentLanguage: 'ko', + }); + openDropdown(container); + + const japaneseLink = screen.getByText('日本語').closest('a'); + const koreanLink = screen.getByText('한국어').closest('a'); + + expect(japaneseLink).not.toHaveClass('bg-gray-100'); + expect(koreanLink).toHaveClass('bg-gray-100'); + }); + + it('does not highlight active language when href is a path and currentLanguage is not provided', () => { + const { container } = renderComponent({ href: '/some/page' }); + openDropdown(container); + + const englishLink = screen.getByText('English').closest('a'); + const chineseLink = screen.getByText('简体中文').closest('a'); + + expect(englishLink).not.toHaveClass('bg-gray-100'); + expect(chineseLink).not.toHaveClass('bg-gray-100'); + }); + + it('correctly highlights when currentLanguage prop is provided', () => { + const { container } = renderComponent({ currentLanguage: 'zh-Hans' }); + openDropdown(container); + + const englishLink = screen.getByText('English').closest('a'); + const chineseLink = screen.getByText('简体中文').closest('a'); + + expect(englishLink).not.toHaveClass('bg-gray-100'); + expect(chineseLink).toHaveClass('bg-gray-100'); + }); }); }); diff --git a/packages/ui/src/__tests__/i18n.test.ts b/packages/ui/src/__tests__/i18n.test.ts new file mode 100644 index 0000000..00c0616 --- /dev/null +++ b/packages/ui/src/__tests__/i18n.test.ts @@ -0,0 +1,177 @@ +import { describe, it, expect } from 'vitest'; +import { getLanguageUrl, getI18nLinks, LanguageOption } from '../utils/i18n'; + +describe('i18n utilities', () => { + describe('getLanguageUrl', () => { + it('returns the domain when no href is provided', () => { + const domain = 'https://tanstack.com'; + expect(getLanguageUrl(domain)).toBe(domain); + }); + + it('appends path to domain when href starts with /', () => { + const domain = 'https://tanstack.com'; + const href = '/query/latest/docs'; + expect(getLanguageUrl(domain, href)).toBe( + 'https://tanstack.com/query/latest/docs' + ); + }); + + it('extracts path from full URL and appends to domain', () => { + const domain = 'https://zh-hans.tanstack.dev'; + const href = 'https://tanstack.com/query/latest/docs?param=value#hash'; + + expect(getLanguageUrl(domain, href)).toBe( + 'https://zh-hans.tanstack.dev/query/latest/docs?param=value#hash' + ); + }); + + it('preserves query parameters and hash fragments', () => { + const domain = 'https://tanstack.com'; + const href = 'https://example.com/page?query=test&sort=asc#section-2'; + + expect(getLanguageUrl(domain, href)).toBe( + 'https://tanstack.com/page?query=test&sort=asc#section-2' + ); + }); + + it('returns domain for invalid URLs', () => { + const domain = 'https://tanstack.com'; + const invalidHref = 'not-a-url-or-path'; + + expect(getLanguageUrl(domain, invalidHref)).toBe(domain); + }); + + it('handles empty paths correctly', () => { + const domain = 'https://tanstack.com'; + const href = 'https://example.com'; + + expect(getLanguageUrl(domain, href)).toBe('https://tanstack.com/'); + }); + + it('handles root path correctly', () => { + const domain = 'https://tanstack.com'; + const href = '/'; + + expect(getLanguageUrl(domain, href)).toBe('https://tanstack.com/'); + }); + }); + + describe('getI18nLinks', () => { + it('generates correct alternate links for default language options', () => { + const href = '/query/latest/docs'; + const links = getI18nLinks({ href }); + + expect(links).toHaveLength(3); // 2 language options + x-default + + // Check English link + expect(links[0]).toEqual({ + rel: 'alternate', + hrefLang: 'en', + href: 'https://tanstack.com/query/latest/docs', + }); + + // Check Chinese link + expect(links[1]).toEqual({ + rel: 'alternate', + hrefLang: 'zh-Hans', + href: 'https://zh-hans.tanstack.dev/query/latest/docs', + }); + + // Check x-default link + expect(links[2]).toEqual({ + rel: 'alternate', + hrefLang: 'x-default', + href: 'https://tanstack.com/query/latest/docs', + }); + }); + + it('generates correct links with custom language options', () => { + const href = '/some/page'; + const customOptions: LanguageOption[] = [ + { + label: 'Français', + domain: 'https://fr.tanstack.com', + langCode: 'fr', + }, + { label: 'Español', domain: 'https://es.tanstack.com', langCode: 'es' }, + { label: 'English', domain: 'https://tanstack.com', langCode: 'en' }, + ]; + + const links = getI18nLinks({ href, languageOptions: customOptions }); + + expect(links).toHaveLength(4); // 3 language options + x-default + + // Check French link + expect(links[0]).toEqual({ + rel: 'alternate', + hrefLang: 'fr', + href: 'https://fr.tanstack.com/some/page', + }); + + // Check Spanish link + expect(links[1]).toEqual({ + rel: 'alternate', + hrefLang: 'es', + href: 'https://es.tanstack.com/some/page', + }); + + // Check English link + expect(links[2]).toEqual({ + rel: 'alternate', + hrefLang: 'en', + href: 'https://tanstack.com/some/page', + }); + + // Check x-default link (should be English) + expect(links[3]).toEqual({ + rel: 'alternate', + hrefLang: 'x-default', + href: 'https://tanstack.com/some/page', + }); + }); + + it('uses first option as x-default when English is not available', () => { + const href = '/some/path'; + const customOptions: LanguageOption[] = [ + { + label: 'Français', + domain: 'https://fr.tanstack.com', + langCode: 'fr', + }, + { label: 'Español', domain: 'https://es.tanstack.com', langCode: 'es' }, + ]; + + const links = getI18nLinks({ href, languageOptions: customOptions }); + + expect(links).toHaveLength(3); // 2 language options + x-default + + // Check x-default link (should be first option, French) + expect(links[2]).toEqual({ + rel: 'alternate', + hrefLang: 'x-default', + href: 'https://fr.tanstack.com/some/path', + }); + }); + + it('handles full URLs correctly', () => { + const href = + 'https://tanstack.com/query/latest/docs?param=value#section-1'; + const links = getI18nLinks({ href }); + + // Check links have correct paths with query params and hash + expect(links[0].href).toBe( + 'https://tanstack.com/query/latest/docs?param=value#section-1' + ); + expect(links[1].href).toBe( + 'https://zh-hans.tanstack.dev/query/latest/docs?param=value#section-1' + ); + }); + + it('works with an empty language options array', () => { + const href = '/path'; + const links = getI18nLinks({ href, languageOptions: [] }); + + expect(links).toEqual([]); + }); + }); +}); diff --git a/packages/ui/src/components/I18nToggle.tsx b/packages/ui/src/components/I18nToggle.tsx index 486e640..431a75e 100644 --- a/packages/ui/src/components/I18nToggle.tsx +++ b/packages/ui/src/components/I18nToggle.tsx @@ -1,19 +1,83 @@ import { MdOutlineTranslate } from 'react-icons/md'; -import { ComponentProps } from 'react'; +import { ComponentProps, useState, useRef, useEffect } from 'react'; +import { + defaultLanguageOptions, + getLanguageUrl, + LanguageCode, + LanguageOption, +} from '../utils/i18n'; interface I18nToggleProps extends ComponentProps<'button'> { className?: string; + href?: string; // Current URL to maintain path when switching languages + languageOptions?: LanguageOption[]; + currentLanguage?: LanguageCode; } -const I18nToggle = ({ className, ...props }: I18nToggleProps) => { +const I18nToggle = ({ + className, + href, + languageOptions = defaultLanguageOptions, + currentLanguage, + ...props +}: I18nToggleProps) => { + const [isOpen, setIsOpen] = useState(false); + const dropdownRef = useRef(null); + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if ( + dropdownRef.current && + !dropdownRef.current.contains(event.target as Node) + ) { + setIsOpen(false); + } + }; + + document.addEventListener('mousedown', handleClickOutside); + return () => { + document.removeEventListener('mousedown', handleClickOutside); + }; + }, []); + return ( - +
+ + +
+
+ {languageOptions.map((option) => { + const isActive = option.langCode === currentLanguage; + return ( + setIsOpen(false)} + > + {option.label} + + ); + })} +
+
+
); }; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index b0679e6..a4742d1 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1 +1,2 @@ export { default as I18nToggle } from './components/I18nToggle'; +export { getI18nLinks } from './utils/i18n' \ No newline at end of file diff --git a/packages/ui/src/utils/i18n.ts b/packages/ui/src/utils/i18n.ts new file mode 100644 index 0000000..1924954 --- /dev/null +++ b/packages/ui/src/utils/i18n.ts @@ -0,0 +1,65 @@ +export interface LanguageOption { + label: string; + domain: string; + langCode: string; // Added for hreflang +} + +export const defaultLanguageOptions: LanguageOption[] = [ + { label: 'English', domain: 'https://tanstack.com', langCode: 'en' }, + { + label: '简体中文', + domain: 'https://zh-hans.tanstack.dev', + langCode: 'zh-Hans', + }, +]; + +export type LanguageCode = 'en' | 'zh-Hans'; + +// Get path from current URL if href is provided +export const getLanguageUrl = (domain: string, href?: string) => { + if (!href) return domain; + + // Check if href is a full URL or just a path + if (href.startsWith('/')) { + return `${domain}${href}`; + } + + try { + const currentUrl = new URL(href); + const path = currentUrl.pathname + currentUrl.search + currentUrl.hash; + return `${domain}${path}`; + } catch (e) { + // If href is not a valid URL (and not a path starting with '/'), return the domain + // This case might occur if href is an invalid string that doesn't represent a URL or a path. + return domain; + } +}; + +export function getI18nLinks({ + href, + languageOptions = defaultLanguageOptions, +}: { + href: string; + languageOptions?: LanguageOption[]; +}) { + const links = languageOptions.map((option) => ({ + rel: 'alternate', + hrefLang: option.langCode, + href: getLanguageUrl(option.domain, href), + })); + + // Add x-default link (usually the first option or a specified default) + // Assuming the first option in defaultLanguageOptions is the default (English) + // https://developers.google.com/search/blog/2013/04/x-default-hreflang-for-international-pages + const defaultOption = + languageOptions.find((opt) => opt.langCode === 'en') || languageOptions[0]; + if (defaultOption) { + links.push({ + rel: 'alternate', + hrefLang: 'x-default', + href: getLanguageUrl(defaultOption.domain, href), + }); + } + + return links; +} From dcddffa002da90f7655fa3220214c3120f9ea66c Mon Sep 17 00:00:00 2001 From: "yanqi.zong" Date: Wed, 7 May 2025 12:08:45 -0700 Subject: [PATCH 2/2] feat: use @tanstack/react-router inside the i18n --- apps/example/src/routes/__root.tsx | 11 +----- package.json | 2 +- packages/ui/package.json | 1 + packages/ui/src/__tests__/I18nToggle.test.tsx | 5 +++ packages/ui/src/components/I18nToggle.tsx | 34 ++++++++++++++++--- packages/ui/vite.config.ts | 30 +++++++++------- pnpm-lock.yaml | 27 +++++++++++++++ 7 files changed, 83 insertions(+), 27 deletions(-) diff --git a/apps/example/src/routes/__root.tsx b/apps/example/src/routes/__root.tsx index b10ebd2..85221d7 100644 --- a/apps/example/src/routes/__root.tsx +++ b/apps/example/src/routes/__root.tsx @@ -90,11 +90,6 @@ export const Route = createRootRoute({ function RootDocument() { const detailsRef = React.useRef(null!); const linkClasses = `flex items-center justify-between group px-2 py-1 rounded-lg hover:bg-gray-500 hover:bg-opacity-10 font-black`; - - const href = useLocation({ - select: (location) => location.href, - }); - const items = ( <> {libraries.map((library, i) => { @@ -299,11 +294,7 @@ function RootDocument() { > - +
diff --git a/package.json b/package.json index 2f2c1df..789bb1b 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "description": "tanstack dev", "scripts": { - "test": "pnpm run -r test", + "test": "pnpm run --filter @tanstack-dev/components test", "dev": "pnpm run --parallel --filter @tanstack-dev/components --filter example dev" }, "author": "zongyanqi@gmail.com", diff --git a/packages/ui/package.json b/packages/ui/package.json index dfc075c..49f4d04 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -15,6 +15,7 @@ "prepublishOnly": "pnpm build" }, "dependencies": { + "@tanstack/react-router": "1.109.2", "jsdom": "^24.0.0", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/packages/ui/src/__tests__/I18nToggle.test.tsx b/packages/ui/src/__tests__/I18nToggle.test.tsx index daa9b37..32e7be4 100644 --- a/packages/ui/src/__tests__/I18nToggle.test.tsx +++ b/packages/ui/src/__tests__/I18nToggle.test.tsx @@ -9,6 +9,11 @@ import { describe, it, expect, afterEach, vi } from 'vitest'; import { I18nToggle } from '../index'; import { LanguageOption } from '../utils/i18n'; +// Mock the TanStack Router's useLocation hook +vi.mock('@tanstack/react-router', () => ({ + useLocation: () => 'https://tanstack.com/mocked-path', +})); + describe('I18nToggle Component', () => { // Helper functions const openDropdown = (container: HTMLElement) => { diff --git a/packages/ui/src/components/I18nToggle.tsx b/packages/ui/src/components/I18nToggle.tsx index 431a75e..f62b3f2 100644 --- a/packages/ui/src/components/I18nToggle.tsx +++ b/packages/ui/src/components/I18nToggle.tsx @@ -1,5 +1,6 @@ import { MdOutlineTranslate } from 'react-icons/md'; import { ComponentProps, useState, useRef, useEffect } from 'react'; +import { useLocation } from '@tanstack/react-router'; import { defaultLanguageOptions, getLanguageUrl, @@ -16,13 +17,36 @@ interface I18nToggleProps extends ComponentProps<'button'> { const I18nToggle = ({ className, - href, + href: hrefProp, languageOptions = defaultLanguageOptions, - currentLanguage, + currentLanguage: currentLanguageProp, ...props }: I18nToggleProps) => { + const routerLocation = useLocation({ + select: (location) => { + return location.href; + }, + }); + + const href = hrefProp || routerLocation; const [isOpen, setIsOpen] = useState(false); const dropdownRef = useRef(null); + const [detectedLanguage, setDetectedLanguage] = useState( + null + ); + + // Detect language from html.meta when currentLanguage is not provided + useEffect(() => { + if (typeof document !== 'undefined') { + const htmlLang = document.documentElement.lang as LanguageCode; + if (htmlLang) { + setDetectedLanguage(htmlLang); + } + } + }, []); + + // Use currentLanguageProp if provided, otherwise use the detected language + const currentLanguage = currentLanguageProp || detectedLanguage; // Close dropdown when clicking outside useEffect(() => { @@ -44,7 +68,7 @@ const I18nToggle = ({ return (