🌐(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:
Emmanuel Pelletier
2024-07-21 16:02:08 +02:00
parent d6b5e9a50c
commit e6c292ecd7
8 changed files with 188 additions and 42 deletions

View 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>
)
}

View 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] }
}

View File

@@ -1,9 +1,11 @@
import { useTranslation } from 'react-i18next' import { Link } from 'wouter'
import { css } from '@/styled-system/css' import { css } from '@/styled-system/css'
import { flex } from '@/styled-system/patterns' 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 { A, Badge, Text } from '@/primitives'
import { authUrl, logoutUrl, useUser } from '@/features/auth' import { authUrl, logoutUrl, useUser } from '@/features/auth'
import { Link } from 'wouter'
export const Header = () => { export const Header = () => {
const { t } = useTranslation() const { t } = useTranslation()
@@ -21,29 +23,27 @@ export const Header = () => {
boxShadow: 'box', boxShadow: 'box',
})} })}
> >
<header <Stack direction="row" justify="space-between" align="center">
className={flex({ <header>
justify: 'space-between',
align: 'center',
})}
>
<div>
<Text bold variant="h1" margin={false}> <Text bold variant="h1" margin={false}>
<Link to="/">{t('app')}</Link> <Link to="/">{t('app')}</Link>
</Text> </Text>
</div> </header>
<div> <nav>
{isLoggedIn === false && <A href={authUrl()}>{t('login')}</A>} <Stack gap={1} direction="row" align="center">
{!!user && ( <LanguageSelector />
<p className={flex({ gap: 1, align: 'center' })}> {isLoggedIn === false && <A href={authUrl()}>{t('login')}</A>}
<Badge>{user.email}</Badge> {!!user && (
<A href={logoutUrl()} size="small"> <p className={flex({ gap: 1, align: 'center' })}>
{t('logout')} <Badge>{user.email}</Badge>
</A> <A href={logoutUrl()} size="small">
</p> {t('logout')}
)} </A>
</div> </p>
</header> )}
</Stack>
</nav>
</Stack>
</div> </div>
) )
} }

View File

@@ -9,6 +9,10 @@
"app": "Meet", "app": "Meet",
"login": "Anmelden", "login": "Anmelden",
"logout": "", "logout": "",
"languageSelector": {
"popoverLabel": "",
"buttonLabel": ""
},
"loading": "", "loading": "",
"notFound": { "notFound": {
"heading": "" "heading": ""

View File

@@ -9,6 +9,10 @@
"app": "Meet", "app": "Meet",
"login": "Login", "login": "Login",
"logout": "Logout", "logout": "Logout",
"languageSelector": {
"popoverLabel": "Choose language",
"buttonLabel": "Change language (currently {{currentLanguage}})"
},
"loading": "Loading…", "loading": "Loading…",
"notFound": { "notFound": {
"heading": "" "heading": ""

View File

@@ -9,6 +9,10 @@
"app": "Meet", "app": "Meet",
"login": "Se connecter", "login": "Se connecter",
"logout": "Se déconnecter", "logout": "Se déconnecter",
"languageSelector": {
"popoverLabel": "Choix de la langue",
"buttonLabel": "Changer de langue (actuellement {{currentLanguage}})"
},
"loading": "Chargement…", "loading": "Chargement…",
"notFound": { "notFound": {
"heading": "Page introuvable" "heading": "Page introuvable"

View File

@@ -9,44 +9,69 @@ import { cva, type RecipeVariantProps } from '@/styled-system/css'
const button = cva({ const button = cva({
base: { base: {
display: 'inline-block', display: 'inline-block',
paddingX: '1', transition: 'background 200ms',
paddingY: '0.625',
transition: 'all 200ms',
borderRadius: 8,
cursor: 'pointer', cursor: 'pointer',
border: '1px solid transparent',
color: 'colorPalette.text',
backgroundColor: 'colorPalette',
'_ra-hover': {
backgroundColor: 'colorPalette.hover',
},
'_ra-pressed': {
backgroundColor: 'colorPalette.active',
},
}, },
variants: { variants: {
size: {
default: {
borderRadius: 8,
paddingX: '1',
paddingY: '0.625',
},
sm: {
borderRadius: 4,
paddingX: '0.5',
paddingY: '0.25',
},
},
variant: { variant: {
default: { default: {
color: 'control.text', colorPalette: 'control',
backgroundColor: 'control',
'_ra-hover': {
backgroundColor: 'control.hover',
},
'_ra-pressed': {
backgroundColor: 'control.active',
},
}, },
primary: { primary: {
color: 'primary.text', colorPalette: 'primary',
backgroundColor: 'primary', },
},
outline: {
true: {
color: 'colorPalette',
backgroundColor: 'transparent!',
borderColor: 'currentcolor!',
'_ra-hover': { '_ra-hover': {
backgroundColor: 'primary.hover', backgroundColor: 'colorPalette.subtle!',
}, },
'_ra-pressed': { '_ra-pressed': {
backgroundColor: 'primary.active', backgroundColor: 'colorPalette.subtle!',
}, },
}, },
}, },
}, },
defaultVariants: {
size: 'default',
variant: 'default',
outline: false,
},
}) })
type ButtonProps = RecipeVariantProps<typeof button> & export type ButtonProps = RecipeVariantProps<typeof button> & RACButtonsProps
(RACButtonsProps | LinkProps)
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) 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 <Link className={button(variantProps)} {...componentProps} />
} }
return ( return (

View 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>
)
}