feat: add language selector to login page (#1431)
Some checks are pending
Build / build (v26.5.0) (push) Waiting to run
CodeQL / Analyze (push) Waiting to run
Deploy Storybook / deploy (push) Waiting to run

* chore: remove wrapper from language selector and move to ui module

* chore: rename SettingItem -> SettingsItem

* chore(settings): hover and pointer only for items with action

* feat: add language selector to login page
This commit is contained in:
Thebora Kompanioni 2026-08-13 08:23:27 +02:00 committed by GitHub
parent 0b707f44fc
commit a1abd6c7e6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 192 additions and 92 deletions

View file

@ -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 = ({
<>
<Card className="w-full max-w-md">
<CardHeader className="flex flex-col items-center space-y-2">
<div className="bg-primary/10 mb-4 flex h-12 w-12 items-center justify-center rounded-full">
{listWalletsFetching ? (
<Spinner className="size-6" />
) : (
<div className="mb-4 grid w-full grid-cols-3">
<div></div>
<Button
type="button"
variant="default"
size="icon-xxl"
className="bg-primary/10 text-primary/80 hover:text-primary self-center justify-self-center rounded-full"
title={t('global.refresh')}
aria-label={t('global.refresh')}
onClick={() => void onReloadClick()}
disabled={listWalletsFetching}
>
{listWalletsFetching ? <Spinner className="size-6" /> : <WalletIcon className="size-5" />}
<span className="sr-only">{t('global.refresh')}</span>
</Button>
<LanguageSelectorDropdownMenu align="end">
<Button
type="button"
variant="ghost"
size="icon"
className="text-primary h-10 w-10 rounded-full"
title={t('global.retry')}
aria-label={t('global.retry')}
onClick={() => void onReloadClick()}
aria-label={t('settings.label_select_language_aria_label')}
title={t('settings.label_select_language_title')}
className="self-start justify-self-end"
>
<WalletIcon />
<LanguagesIcon className="size-5" />
<span className="sr-only">{t('settings.label_select_language')}</span>
</Button>
)}
</LanguageSelectorDropdownMenu>
</div>
<CardTitle className="text-center text-2xl font-bold break-words">{t('login.title')}</CardTitle>
{listWalletsLoading ? (
@ -103,7 +118,7 @@ export const LoginCard = ({
) : null}
</CardHeader>
<CardContent className="space-y-6">
<CardContent className="flex flex-col gap-6">
{listWalletsError ? (
<>
<WalletLoadErrorAlert reason={getErrorReason(listWalletsError, t('global.errors.reason_unknown'))} />

View file

@ -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 (
<div className="flex flex-col items-stretch justify-between gap-2 py-2 sm:flex-row sm:items-center">
<div className="flex min-w-0 items-center gap-2">
<div className="bg-muted/50 flex h-7 w-7 shrink-0 items-center justify-center rounded-lg border">
<GlobeIcon className="text-muted-foreground h-3 w-3" />
</div>
<div>
<p className="text-sm font-medium">{t('settings.label_select_language')}</p>
</div>
</div>
<Select value={i18n.resolvedLanguage} onValueChange={(value) => void handleLanguageChange(value)}>
<SelectTrigger className="h-7 w-full text-xs sm:w-38" aria-label="Select language">
<SelectValue placeholder={getCurrentLanguageDescription()} />
</SelectTrigger>
<SelectContent>
{languages.map((language) => (
<SelectItem key={language.key} value={language.key}>
{language.description}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}

View file

@ -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(<SettingItem icon={KeyRoundIcon} title="settings.show_seed" />)
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" />)
expect(screen.getByText('settings.show_seed')).toBeInTheDocument()
})
it('exposes an actionable row as a button', () => {
render(<SettingItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} />)
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} />)
expect(screen.getByRole('button', { name: 'settings.show_seed' })).toBeInTheDocument()
})
it('does not render a button without an action', () => {
render(<SettingItem icon={KeyRoundIcon} title="settings.show_seed" />)
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" />)
expect(screen.queryByRole('button')).not.toBeInTheDocument()
})
it('is reachable by keyboard', async () => {
render(<SettingItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} />)
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} />)
await user.tab()
@ -63,7 +63,7 @@ describe('SettingItem', () => {
it('triggers the action on Enter', async () => {
const action = vi.fn()
render(<SettingItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
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(<SettingItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
await user.tab()
await user.keyboard(' ')
@ -83,7 +83,7 @@ describe('SettingItem', () => {
it('triggers the action on click', async () => {
const action = vi.fn()
render(<SettingItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} />)
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(<SettingItem icon={KeyRoundIcon} title="settings.show_seed" action={action} disabled={true} />)
render(<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={action} disabled={true} />)
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(
<SettingItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} hasInteractiveChild={true}>
<SettingsItem icon={KeyRoundIcon} title="settings.show_seed" action={vi.fn()} hasInteractiveChild={true}>
<button type="button">child-control</button>
</SettingItem>,
</SettingsItem>,
)
// 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(
<SettingSwitch
<SettingsSwitch
icon={KeyRoundIcon}
title="settings.hide_balance"
checked={false}
@ -165,7 +165,7 @@ describe('SettingSwitch', () => {
it('is keyboard operable via the toggle when one is displayed', async () => {
const onCheckedChange = vi.fn()
render(
<SettingSwitch
<SettingsSwitch
icon={KeyRoundIcon}
title="settings.use_address_chunking_enabled"
checked={false}

View file

@ -19,7 +19,7 @@ type SettingsItemProps = PropsWithChildren<{
hasInteractiveChild?: boolean
}>
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 (
<SettingItem
<SettingsItem
{...props}
action={async () => {
if (external) {
@ -98,7 +99,7 @@ export const SettingsLink = ({ to, external = false, ...props }: SettingsLinkPro
}}
>
{external && <ExternalLinkIcon className="text-muted-foreground size-3.5" />}
</SettingItem>
</SettingsItem>
)
}
@ -107,13 +108,13 @@ type SettingsSwitchProps = Omit<SettingsItemProps, 'children' | 'action' | 'hasI
onCheckedChange?: (checked: boolean) => 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.
<SettingItem {...props} action={() => onCheckedChange?.(!checked)} hasInteractiveChild={displayToggle}>
<SettingsItem {...props} action={() => onCheckedChange?.(!checked)} hasInteractiveChild={displayToggle}>
{displayToggle && <Switch checked={checked} onCheckedChange={onCheckedChange} disabled={props.disabled} />}
</SettingItem>
</SettingsItem>
)
}

View file

@ -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: () => <div>language-selector</div>,
}))

View file

@ -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)
<CardTitle>{t('settings.section_title_display')}</CardTitle>
</CardHeader>
<CardContent>
<SettingSwitch
<SettingsSwitch
icon={isPrivate ? EyeIcon : EyeOffIcon}
title={t(isPrivate ? 'settings.show_balance' : 'settings.hide_balance')}
checked={isPrivate}
@ -89,7 +90,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
displayToggle={false}
/>
<Separator className="opacity-50" />
<SettingSwitch
<SettingsSwitch
renderIcon={({ className }) => <CurrencySymbol currency={currency} className={className} />}
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}
/>
<Separator className="opacity-50" />
<SettingSwitch
<SettingsSwitch
icon={addressChunkingEnabled === true ? UnfoldHorizontalIcon : FoldHorizontalIcon}
title={t(
addressChunkingEnabled === true
@ -109,7 +110,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
displayToggle={true}
/>
<Separator className="opacity-50" />
<SettingSwitch
<SettingsSwitch
icon={resolvedTheme === 'dark' ? SunIcon : MoonIcon}
title={resolvedTheme === 'dark' ? t('settings.use_light_theme') : t('settings.use_dark_theme')}
checked={resolvedTheme === 'dark'}
@ -117,7 +118,9 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
displayToggle={false}
/>
<Separator className="opacity-50" />
<LanguageSelector />
<SettingsItem icon={LanguagesIcon} title={t('settings.label_select_language')}>
<LanguageSelector />
</SettingsItem>
</CardContent>
</Card>
@ -127,7 +130,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
<CardTitle>{t('settings.section_title_market')}</CardTitle>
</CardHeader>
<CardContent>
<SettingItem
<SettingsItem
icon={HandCoinsIcon}
title={t('settings.show_fee_config')}
action={() => setShowFeeConfigDialog(true)}
@ -141,21 +144,21 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
<CardTitle>{t('settings.section_title_wallet')}</CardTitle>
</CardHeader>
<CardContent className="space-y-0">
<SettingItem
<SettingsItem
icon={KeyRoundIcon}
title={t('settings.show_seed')}
action={() => setShowSeedDialog(true)}
disabled={hashedPassword === undefined}
/>
<Separator className="opacity-50" />
<SettingItem
<SettingsItem
icon={BookKeyIcon}
title={t('settings.show_xpubs')}
action={() => setShowXpubsDialog(true)}
disabled={hashedPassword === undefined}
/>
<Separator className="opacity-50" />
<SettingItem
<SettingsItem
renderIcon={({ className }) =>
lockWalletMutation.isPending ? (
<Spinner className={className} />
@ -217,7 +220,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
external={true}
/>
<Separator className="opacity-50" />
<SettingSwitch
<SettingsSwitch
icon={SparklesIcon}
title={/* TODO: i18n */ 'Enable Feature Preview'}
disabled={!isDevMode()}
@ -229,7 +232,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
{!!jamSettings.state.previewFeatures && (
<>
<Separator className="opacity-50" />
<SettingSwitch
<SettingsSwitch
icon={HistoryIcon}
title={/* TODO: i18n */ 'Transaction History (Experimental)'}
disabled={!isDevMode()}
@ -248,7 +251,7 @@ export const SettingsPage = ({ walletFileName, onLockWallet }: SettingPageProps)
{isDevMode() && (
<>
<Separator className="opacity-50" />
<SettingSwitch
<SettingsSwitch
icon={TerminalIcon}
title="Enable developer mode"
disabled={!isDevMode()}

View file

@ -40,6 +40,7 @@ const buttonVariants = cva(
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
'icon-sm': 'size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg',
'icon-lg': 'size-9',
'icon-xxl': 'size-12',
},
},
defaultVariants: {

View file

@ -38,6 +38,8 @@ vi.mock('@/components/ui/select', () => ({
SelectTrigger: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectValue: ({ placeholder }: { placeholder?: string }) => <div data-testid="select-value">{placeholder}</div>,
SelectContent: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectGroup: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectLabel: ({ children }: ChildrenProps) => <div>{children}</div>,
SelectItem: ({ children, value }: ChildrenProps & { value: string }) => (
<div data-testid={`select-item-${value}`}>{children}</div>
),

View file

@ -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<typeof Select> & { className?: string } = {}) => {
const { i18n, t } = useTranslation()
const currentLanguage = languages.find((lang) => lang.key === i18n.resolvedLanguage)
const currentLanguageDescription = currentLanguage?.description || 'English'
return (
<Select
value={i18n.resolvedLanguage}
onValueChange={(value) => {
void i18n.changeLanguage(value)
}}
{...props}
>
<SelectTrigger
className={cn('w-full text-xs sm:min-w-42', className)}
aria-label={t('settings.label_select_language_aria_label')}
>
<SelectValue placeholder={currentLanguageDescription} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
<SelectLabel className="flex items-center gap-1">
<LanguagesIcon className="size-4" />
{t('settings.label_select_language')}
</SelectLabel>
{languages.map((language) => (
<SelectItem key={language.key} value={language.key}>
{language.description}
</SelectItem>
))}
</SelectGroup>
</SelectContent>
</Select>
)
}
export const LanguageSelectorDropdownMenu = ({
className,
align,
loop = true,
...props
}: PropsWithChildren<
ComponentProps<typeof DropdownMenu> & {
className?: string
align?: ComponentProps<typeof DropdownMenuContent>['align']
loop?: ComponentProps<typeof DropdownMenuContent>['loop']
}
> = {}) => {
const { i18n, t } = useTranslation()
return (
<DropdownMenu {...props}>
<DropdownMenuTrigger asChild>
{props.children ?? (
<Button
type="button"
variant="ghost"
className={className}
aria-label={t('settings.label_select_language_aria_label')}
>
<LanguagesIcon />
{t('settings.label_select_language')}
</Button>
)}
</DropdownMenuTrigger>
<DropdownMenuContent className="w-full text-xs sm:min-w-56" align={align} loop={loop}>
<DropdownMenuGroup>
<DropdownMenuLabel className="flex items-center gap-1">
<LanguagesIcon className="size-4" />
{t('settings.label_select_language')}
</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={i18n.resolvedLanguage}
onValueChange={(value) => {
void i18n.changeLanguage(value)
}}
>
{languages.map((language) => (
<DropdownMenuRadioItem key={language.key} value={language.key}>
{language.description}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
)
}

View file

@ -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",