mirror of
https://github.com/joinmarket-webui/jam.git
synced 2026-08-13 12:33:26 +02:00
feat: add language selector to login page (#1431)
* 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:
parent
0b707f44fc
commit
a1abd6c7e6
10 changed files with 192 additions and 92 deletions
|
|
@ -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'))} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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()}
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
),
|
||||
116
src/components/ui/jam/LanguageSelector.tsx
Normal file
116
src/components/ui/jam/LanguageSelector.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue