🌐(frontend) add a language selector in the header
this will be better in an options page later i think, as we don't pass our life changing language and we already have a language detector at load. this adds a PopoverList primitive to easily create buttons triggering popovers containing list of actionable items.
This commit is contained in:
28
src/frontend/src/i18n/LanguageSelector.tsx
Normal file
28
src/frontend/src/i18n/LanguageSelector.tsx
Normal file
@@ -0,0 +1,28 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Button, Popover, PopoverList } from '@/primitives'
|
||||
import { useLanguageLabels } from './useLanguageLabels'
|
||||
|
||||
export const LanguageSelector = () => {
|
||||
const { t, i18n } = useTranslation()
|
||||
const { languagesList, currentLanguage } = useLanguageLabels()
|
||||
return (
|
||||
<Popover aria-label={t('languageSelector.popoverLabel')}>
|
||||
<Button
|
||||
aria-label={t('languageSelector.buttonLabel', {
|
||||
currentLanguage,
|
||||
})}
|
||||
size="sm"
|
||||
variant="primary"
|
||||
outline
|
||||
>
|
||||
{i18n.language}
|
||||
</Button>
|
||||
<PopoverList
|
||||
items={languagesList}
|
||||
onAction={(lang) => {
|
||||
i18n.changeLanguage(lang)
|
||||
}}
|
||||
/>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
20
src/frontend/src/i18n/useLanguageLabels.ts
Normal file
20
src/frontend/src/i18n/useLanguageLabels.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
const langageLabels: Record<string, string> = {
|
||||
en: 'English',
|
||||
fr: 'Français',
|
||||
de: 'Deutsch',
|
||||
}
|
||||
|
||||
export const useLanguageLabels = () => {
|
||||
const { i18n } = useTranslation()
|
||||
// cimode is a testing value from i18next, don't include it
|
||||
const supportedLanguages = (i18n.options.supportedLngs || []).filter(
|
||||
(lang) => lang !== 'cimode'
|
||||
)
|
||||
const languagesList = supportedLanguages.map((lang) => ({
|
||||
value: lang,
|
||||
label: langageLabels[lang],
|
||||
}))
|
||||
return { languagesList, currentLanguage: langageLabels[i18n.language] }
|
||||
}
|
||||
@@ -1,9 +1,11 @@
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { Link } from 'wouter'
|
||||
import { css } from '@/styled-system/css'
|
||||
import { flex } from '@/styled-system/patterns'
|
||||
import { Stack } from '@/styled-system/jsx'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { LanguageSelector } from '@/i18n/LanguageSelector'
|
||||
import { A, Badge, Text } from '@/primitives'
|
||||
import { authUrl, logoutUrl, useUser } from '@/features/auth'
|
||||
import { Link } from 'wouter'
|
||||
|
||||
export const Header = () => {
|
||||
const { t } = useTranslation()
|
||||
@@ -21,29 +23,27 @@ export const Header = () => {
|
||||
boxShadow: 'box',
|
||||
})}
|
||||
>
|
||||
<header
|
||||
className={flex({
|
||||
justify: 'space-between',
|
||||
align: 'center',
|
||||
})}
|
||||
>
|
||||
<div>
|
||||
<Stack direction="row" justify="space-between" align="center">
|
||||
<header>
|
||||
<Text bold variant="h1" margin={false}>
|
||||
<Link to="/">{t('app')}</Link>
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
{isLoggedIn === false && <A href={authUrl()}>{t('login')}</A>}
|
||||
{!!user && (
|
||||
<p className={flex({ gap: 1, align: 'center' })}>
|
||||
<Badge>{user.email}</Badge>
|
||||
<A href={logoutUrl()} size="small">
|
||||
{t('logout')}
|
||||
</A>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
</header>
|
||||
<nav>
|
||||
<Stack gap={1} direction="row" align="center">
|
||||
<LanguageSelector />
|
||||
{isLoggedIn === false && <A href={authUrl()}>{t('login')}</A>}
|
||||
{!!user && (
|
||||
<p className={flex({ gap: 1, align: 'center' })}>
|
||||
<Badge>{user.email}</Badge>
|
||||
<A href={logoutUrl()} size="small">
|
||||
{t('logout')}
|
||||
</A>
|
||||
</p>
|
||||
)}
|
||||
</Stack>
|
||||
</nav>
|
||||
</Stack>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
"app": "Meet",
|
||||
"login": "Anmelden",
|
||||
"logout": "",
|
||||
"languageSelector": {
|
||||
"popoverLabel": "",
|
||||
"buttonLabel": ""
|
||||
},
|
||||
"loading": "",
|
||||
"notFound": {
|
||||
"heading": ""
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
"app": "Meet",
|
||||
"login": "Login",
|
||||
"logout": "Logout",
|
||||
"languageSelector": {
|
||||
"popoverLabel": "Choose language",
|
||||
"buttonLabel": "Change language (currently {{currentLanguage}})"
|
||||
},
|
||||
"loading": "Loading…",
|
||||
"notFound": {
|
||||
"heading": ""
|
||||
|
||||
@@ -9,6 +9,10 @@
|
||||
"app": "Meet",
|
||||
"login": "Se connecter",
|
||||
"logout": "Se déconnecter",
|
||||
"languageSelector": {
|
||||
"popoverLabel": "Choix de la langue",
|
||||
"buttonLabel": "Changer de langue (actuellement {{currentLanguage}})"
|
||||
},
|
||||
"loading": "Chargement…",
|
||||
"notFound": {
|
||||
"heading": "Page introuvable"
|
||||
|
||||
@@ -9,44 +9,69 @@ import { cva, type RecipeVariantProps } from '@/styled-system/css'
|
||||
const button = cva({
|
||||
base: {
|
||||
display: 'inline-block',
|
||||
paddingX: '1',
|
||||
paddingY: '0.625',
|
||||
transition: 'all 200ms',
|
||||
borderRadius: 8,
|
||||
transition: 'background 200ms',
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
color: 'colorPalette.text',
|
||||
backgroundColor: 'colorPalette',
|
||||
'_ra-hover': {
|
||||
backgroundColor: 'colorPalette.hover',
|
||||
},
|
||||
'_ra-pressed': {
|
||||
backgroundColor: 'colorPalette.active',
|
||||
},
|
||||
},
|
||||
variants: {
|
||||
size: {
|
||||
default: {
|
||||
borderRadius: 8,
|
||||
paddingX: '1',
|
||||
paddingY: '0.625',
|
||||
},
|
||||
sm: {
|
||||
borderRadius: 4,
|
||||
paddingX: '0.5',
|
||||
paddingY: '0.25',
|
||||
},
|
||||
},
|
||||
variant: {
|
||||
default: {
|
||||
color: 'control.text',
|
||||
backgroundColor: 'control',
|
||||
'_ra-hover': {
|
||||
backgroundColor: 'control.hover',
|
||||
},
|
||||
'_ra-pressed': {
|
||||
backgroundColor: 'control.active',
|
||||
},
|
||||
colorPalette: 'control',
|
||||
},
|
||||
primary: {
|
||||
color: 'primary.text',
|
||||
backgroundColor: 'primary',
|
||||
colorPalette: 'primary',
|
||||
},
|
||||
},
|
||||
outline: {
|
||||
true: {
|
||||
color: 'colorPalette',
|
||||
backgroundColor: 'transparent!',
|
||||
borderColor: 'currentcolor!',
|
||||
'_ra-hover': {
|
||||
backgroundColor: 'primary.hover',
|
||||
backgroundColor: 'colorPalette.subtle!',
|
||||
},
|
||||
'_ra-pressed': {
|
||||
backgroundColor: 'primary.active',
|
||||
backgroundColor: 'colorPalette.subtle!',
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
size: 'default',
|
||||
variant: 'default',
|
||||
outline: false,
|
||||
},
|
||||
})
|
||||
|
||||
type ButtonProps = RecipeVariantProps<typeof button> &
|
||||
(RACButtonsProps | LinkProps)
|
||||
export type ButtonProps = RecipeVariantProps<typeof button> & RACButtonsProps
|
||||
|
||||
export const Button = (props: ButtonProps) => {
|
||||
type LinkButtonProps = RecipeVariantProps<typeof button> & LinkProps
|
||||
|
||||
type ButtonOrLinkProps = ButtonProps | LinkButtonProps
|
||||
|
||||
export const Button = (props: ButtonOrLinkProps) => {
|
||||
const [variantProps, componentProps] = button.splitVariantProps(props)
|
||||
if ((props as LinkProps).href !== undefined) {
|
||||
if ((props as LinkButtonProps).href !== undefined) {
|
||||
return <Link className={button(variantProps)} {...componentProps} />
|
||||
}
|
||||
return (
|
||||
|
||||
61
src/frontend/src/primitives/PopoverList.tsx
Normal file
61
src/frontend/src/primitives/PopoverList.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
import { ReactNode, useContext } from 'react'
|
||||
import {
|
||||
ButtonProps,
|
||||
Button,
|
||||
OverlayTriggerStateContext,
|
||||
} from 'react-aria-components'
|
||||
import { styled } from '@/styled-system/jsx'
|
||||
|
||||
const ListItem = styled(Button, {
|
||||
base: {
|
||||
paddingY: 0.125,
|
||||
paddingX: 0.5,
|
||||
textAlign: 'left',
|
||||
width: 'full',
|
||||
borderRadius: 4,
|
||||
cursor: 'pointer',
|
||||
color: 'primary',
|
||||
'_ra-hover': {
|
||||
color: 'primary.subtle-text',
|
||||
backgroundColor: 'primary.subtle',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
/**
|
||||
* render a Button primitive that shows a popover showing a list of pressable items
|
||||
*/
|
||||
export const PopoverList = <T extends string | number = string>({
|
||||
onAction,
|
||||
closeOnAction = true,
|
||||
items = [],
|
||||
}: {
|
||||
closeOnAction?: boolean
|
||||
onAction: (key: T) => void
|
||||
items: Array<string | { value: T; label: ReactNode }>
|
||||
} & ButtonProps) => {
|
||||
const popoverState = useContext(OverlayTriggerStateContext)!
|
||||
return (
|
||||
<ul>
|
||||
{items.map((item) => {
|
||||
const value = typeof item === 'string' ? item : item.value
|
||||
const label = typeof item === 'string' ? item : item.label
|
||||
return (
|
||||
<li>
|
||||
<ListItem
|
||||
key={value}
|
||||
onPress={() => {
|
||||
onAction(value as T)
|
||||
if (closeOnAction) {
|
||||
popoverState.close()
|
||||
}
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
</ListItem>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user