🌐(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 { 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>
)
}

View File

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

View File

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

View File

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

View File

@@ -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 (

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