diff --git a/src/components/login/LoginCard.tsx b/src/components/login/LoginCard.tsx index 934ddeca..410f8096 100644 --- a/src/components/login/LoginCard.tsx +++ b/src/components/login/LoginCard.tsx @@ -1,9 +1,10 @@ import { useState, type ComponentProps } from 'react' -import { RefreshCwIcon, WalletIcon } from 'lucide-react' +import { LanguagesIcon, RefreshCwIcon, WalletIcon } from 'lucide-react' import { useTranslation } from 'react-i18next' import { useNavigate } from 'react-router-dom' import { Button } from '@/components/ui/button' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { LanguageSelectorDropdownMenu } from '@/components/ui/jam/LanguageSelector' import { WalletLoadErrorAlert } from '@/components/ui/jam/WalletLoadErrorAlert' import { Skeleton } from '@/components/ui/skeleton' import { Spinner } from '@/components/ui/spinner' @@ -71,22 +72,36 @@ export const LoginCard = ({ <> -
- {listWalletsFetching ? ( - - ) : ( +
+
+ + + + - )} +
{t('login.title')} {listWalletsLoading ? ( @@ -103,7 +118,7 @@ export const LoginCard = ({ ) : null} - + {listWalletsError ? ( <> diff --git a/src/components/settings/LanguageSelector.tsx b/src/components/settings/LanguageSelector.tsx deleted file mode 100644 index f65dd5d2..00000000 --- a/src/components/settings/LanguageSelector.tsx +++ /dev/null @@ -1,43 +0,0 @@ -import { GlobeIcon } from 'lucide-react' -import { useTranslation } from 'react-i18next' -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import languages from '@/i18n/languages' - -export const LanguageSelector = () => { - const { i18n, t } = useTranslation() - - const handleLanguageChange = async (languageKey: string) => { - await i18n.changeLanguage(languageKey) - } - - const getCurrentLanguageDescription = () => { - const currentLanguage = languages.find((lang) => lang.key === i18n.resolvedLanguage) - return currentLanguage?.description || 'English' - } - - return ( -
-
-
- -
-
-

{t('settings.label_select_language')}

-
-
- - -
- ) -} diff --git a/src/components/settings/SettingsItem.test.tsx b/src/components/settings/SettingsItem.test.tsx index 1eb8cd2e..f2edd7e3 100644 --- a/src/components/settings/SettingsItem.test.tsx +++ b/src/components/settings/SettingsItem.test.tsx @@ -2,7 +2,7 @@ import { render, screen } from '@testing-library/react' import user from '@testing-library/user-event' import { KeyRoundIcon } from 'lucide-react' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { SettingItem, SettingsLink, SettingSwitch } from './SettingsItem' +import { SettingsItem, SettingsLink, SettingsSwitch } from './SettingsItem' const mocks = vi.hoisted(() => ({ navigate: vi.fn(), @@ -29,32 +29,32 @@ vi.mock('@/components/ui/switch', () => ({ ), })) -describe('SettingItem', () => { +describe('SettingsItem', () => { beforeEach(() => { mocks.navigate.mockReset() mocks.open.mockReset() }) it('renders title and icon', () => { - render() + render() expect(screen.getByText('settings.show_seed')).toBeInTheDocument() }) it('exposes an actionable row as a button', () => { - render() + render() expect(screen.getByRole('button', { name: 'settings.show_seed' })).toBeInTheDocument() }) it('does not render a button without an action', () => { - render() + render() expect(screen.queryByRole('button')).not.toBeInTheDocument() }) it('is reachable by keyboard', async () => { - render() + render() await user.tab() @@ -63,7 +63,7 @@ describe('SettingItem', () => { it('triggers the action on Enter', async () => { const action = vi.fn() - render() + render() await user.tab() await user.keyboard('{Enter}') @@ -73,7 +73,7 @@ describe('SettingItem', () => { it('triggers the action on Space', async () => { const action = vi.fn() - render() + render() await user.tab() await user.keyboard(' ') @@ -83,7 +83,7 @@ describe('SettingItem', () => { it('triggers the action on click', async () => { const action = vi.fn() - render() + render() await user.click(screen.getByRole('button', { name: 'settings.show_seed' })) @@ -92,7 +92,7 @@ describe('SettingItem', () => { it('does not trigger the action while disabled', async () => { const action = vi.fn() - render() + render() const item = screen.getByRole('button', { name: 'settings.show_seed' }) expect(item).toBeDisabled() @@ -106,9 +106,9 @@ describe('SettingItem', () => { it('stays a wrapper when children provide their own control', () => { render( - + - , + , ) // no button is nested inside another button @@ -147,7 +147,7 @@ describe('SettingSwitch', () => { it('is keyboard operable via the row when no toggle is displayed', async () => { const onCheckedChange = vi.fn() render( - { it('is keyboard operable via the toggle when one is displayed', async () => { const onCheckedChange = vi.fn() render( - -export const SettingItem = ({ +export const SettingsItem = ({ icon: Icon, renderIcon, title, @@ -29,7 +29,8 @@ export const SettingItem = ({ children, }: SettingsItemProps) => { const rowClassName = cn('flex min-w-0 items-center justify-between gap-2 py-2', { - 'hover:bg-muted/50 cursor-pointer rounded-md px-2': !disabled, + 'hover:bg-muted/50 cursor-pointer': !disabled && action, + 'rounded-md px-2': !disabled, 'cursor-not-allowed opacity-60': disabled, }) // The row bleeds into the card padding so the hover background spans the full width. @@ -87,7 +88,7 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro const navigate = useNavigate() return ( - { if (external) { @@ -98,7 +99,7 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro }} > {external && } - + ) } @@ -107,13 +108,13 @@ type SettingsSwitchProps = Omit void displayToggle?: boolean } -export const SettingSwitch = ({ checked, onCheckedChange, displayToggle = true, ...props }: SettingsSwitchProps) => { +export const SettingsSwitch = ({ checked, onCheckedChange, displayToggle = true, ...props }: SettingsSwitchProps) => { return ( // With a visible toggle the `Switch` is the focusable control and the row stays a // wrapper. Without one the row itself has to be the button, or the setting cannot be // reached by keyboard at all. - onCheckedChange?.(!checked)} hasInteractiveChild={displayToggle}> + onCheckedChange?.(!checked)} hasInteractiveChild={displayToggle}> {displayToggle && } - + ) } diff --git a/src/components/settings/SettingsPage.test.tsx b/src/components/settings/SettingsPage.test.tsx index 91c4684a..20e58cb3 100644 --- a/src/components/settings/SettingsPage.test.tsx +++ b/src/components/settings/SettingsPage.test.tsx @@ -59,6 +59,9 @@ vi.mock('next-themes', () => ({ vi.mock('react-i18next', () => ({ useTranslation: () => ({ + i18n: { + resolvedLanguage: 'en', + }, t: (key: string) => key, }), })) @@ -123,7 +126,7 @@ vi.mock('@/components/settings/AccountXpubsDialog', () => ({ ) : null, })) -vi.mock('@/components/settings/LanguageSelector', () => ({ +vi.mock('@/components/ui/jam/LanguageSelector', () => ({ LanguageSelector: () =>
language-selector
, })) diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index d0f1337a..3fba6fd4 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -18,6 +18,7 @@ import { HandCoinsIcon, SparklesIcon, HistoryIcon, + LanguagesIcon, } from 'lucide-react' import { useTheme } from 'next-themes' import { useTranslation } from 'react-i18next' @@ -25,8 +26,10 @@ import { useNavigate, type NavigateFunction } from 'react-router-dom' import { useStore } from 'zustand' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { CurrencySymbol } from '@/components/ui/jam/CurrencySymbol' +import { LanguageSelector } from '@/components/ui/jam/LanguageSelector' import PageTitle from '@/components/ui/jam/PageTitle' import { Separator } from '@/components/ui/separator' +import { Spinner } from '@/components/ui/spinner' import { isDebugFeatureEnabled, isDevMode } from '@/constants/debugFeatures' import { JAM_DOCS_URL, JAM_MATRIX_URL, JAM_REPO_URL, JAM_SEED_MODAL_TIMEOUT, JAM_TELEGRAM_URL } from '@/constants/jam' import { routes } from '@/constants/routes' @@ -36,11 +39,9 @@ import { useFeeConfigValidation } from '@/hooks/useFeeConfigValidation' import { cn, type WalletFileName } from '@/lib/utils' import { authStore } from '@/store/authStore' import { jamSettingsStore } from '@/store/jamSettingsStore' -import { Spinner } from '../ui/spinner' import { AccountXpubsDialog } from './AccountXpubsDialog' -import { LanguageSelector } from './LanguageSelector' import { SeedPhraseDialog } from './SeedPhraseDialog' -import { SettingItem, SettingsLink, SettingSwitch } from './SettingsItem' +import { SettingsItem, SettingsLink, SettingsSwitch } from './SettingsItem' import { FeeConfigDialog } from './fees/FeeConfigDialog' interface SettingPageProps { @@ -81,7 +82,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) {t('settings.section_title_display')} - - } title={t(currency === 'btc' ? 'settings.use_btc' : 'settings.use_sats')} checked={currency === 'btc'} @@ -97,7 +98,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) displayToggle={false} /> - - - + + + @@ -127,7 +130,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) {t('settings.section_title_market')} - setShowFeeConfigDialog(true)} @@ -141,21 +144,21 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) {t('settings.section_title_wallet')} - setShowSeedDialog(true)} disabled={hashedPassword === undefined} /> - setShowXpubsDialog(true)} disabled={hashedPassword === undefined} /> - lockWalletMutation.isPending ? ( @@ -217,7 +220,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps) external={true} /> - - - ({ SelectTrigger: ({ children }: ChildrenProps) =>
{children}
, SelectValue: ({ placeholder }: { placeholder?: string }) =>
{placeholder}
, SelectContent: ({ children }: ChildrenProps) =>
{children}
, + SelectGroup: ({ children }: ChildrenProps) =>
{children}
, + SelectLabel: ({ children }: ChildrenProps) =>
{children}
, SelectItem: ({ children, value }: ChildrenProps & { value: string }) => (
{children}
), diff --git a/src/components/ui/jam/LanguageSelector.tsx b/src/components/ui/jam/LanguageSelector.tsx new file mode 100644 index 00000000..d0553a45 --- /dev/null +++ b/src/components/ui/jam/LanguageSelector.tsx @@ -0,0 +1,116 @@ +import type { ComponentProps, PropsWithChildren } from 'react' +import { LanguagesIcon } from 'lucide-react' +import { useTranslation } from 'react-i18next' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, + SelectGroup, + SelectLabel, +} from '@/components/ui/select' +import languages from '@/i18n/languages' +import { cn } from '@/lib/utils' +import { Button } from '../button' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuGroup, + DropdownMenuLabel, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuTrigger, +} from '../dropdown-menu' + +export const LanguageSelector = ({ + className, + ...props +}: ComponentProps & { className?: string } = {}) => { + const { i18n, t } = useTranslation() + const currentLanguage = languages.find((lang) => lang.key === i18n.resolvedLanguage) + const currentLanguageDescription = currentLanguage?.description || 'English' + + return ( + + ) +} + +export const LanguageSelectorDropdownMenu = ({ + className, + align, + loop = true, + ...props +}: PropsWithChildren< + ComponentProps & { + className?: string + align?: ComponentProps['align'] + loop?: ComponentProps['loop'] + } +> = {}) => { + const { i18n, t } = useTranslation() + + return ( + + + {props.children ?? ( + + )} + + + + + + {t('settings.label_select_language')} + + { + void i18n.changeLanguage(value) + }} + > + {languages.map((language) => ( + + {language.description} + + ))} + + + + + ) +} diff --git a/src/i18n/locales/en/translation.json b/src/i18n/locales/en/translation.json index 13bac7c6..b5bb57a2 100644 --- a/src/i18n/locales/en/translation.json +++ b/src/i18n/locales/en/translation.json @@ -293,6 +293,8 @@ "use_address_chunking_enabled": "Address display chunking enabled", "use_address_chunking_disabled": "Address display chunking disabled", "label_select_language": "Language", + "label_select_language_title": "Select language", + "label_select_language_aria_label": "$t(settings.label_select_language_title)", "show_seed": "Show seed phrase", "show_xpubs": "Show account xpubs", "reveal_seed": "Reveal seed phrase",