Localize mismatch actions and polish locale UI

Render the switch-to-saved button in the identity locale,
translate remaining locale stubs, and use a checkmark for
selected dropdown radio items.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-07-21 16:21:55 +02:00
parent 199289b42b
commit 5d161173c8
11 changed files with 94 additions and 60 deletions

View File

@@ -95,13 +95,13 @@
"next": "Halaman berikutnya"
},
"locale": {
"label": "Language",
"updateFailed": "Couldn't update language",
"label": "Bahasa",
"updateFailed": "Tidak dapat memperbarui bahasa",
"mismatch": {
"message": "You're viewing this page in {{language}}.",
"switchToMine": "Switch to {{language}}",
"useThis": "Use {{language}} as my language",
"dismiss": "Dismiss"
"message": "Anda sedang melihat halaman ini dalam {{language}}.",
"switchToMine": "Beralih ke {{language}}",
"useThis": "Gunakan {{language}} sebagai bahasa saya",
"dismiss": "Tutup"
}
}
}

View File

@@ -95,13 +95,13 @@
"next": "Pagina successiva"
},
"locale": {
"label": "Language",
"updateFailed": "Couldn't update language",
"label": "Lingua",
"updateFailed": "Impossibile aggiornare la lingua",
"mismatch": {
"message": "You're viewing this page in {{language}}.",
"switchToMine": "Switch to {{language}}",
"useThis": "Use {{language}} as my language",
"dismiss": "Dismiss"
"message": "Stai visualizzando questa pagina in {{language}}.",
"switchToMine": "Passa a {{language}}",
"useThis": "Usa {{language}} come mia lingua",
"dismiss": "Ignora"
}
}
}

View File

@@ -95,13 +95,13 @@
"next": "次のページ"
},
"locale": {
"label": "Language",
"updateFailed": "Couldn't update language",
"label": "言語",
"updateFailed": "言語を更新できませんでした",
"mismatch": {
"message": "You're viewing this page in {{language}}.",
"switchToMine": "Switch to {{language}}",
"useThis": "Use {{language}} as my language",
"dismiss": "Dismiss"
"message": "このページは{{language}}で表示されています。",
"switchToMine": "{{language}}に切り替え",
"useThis": "{{language}}を自分の言語として使用",
"dismiss": "閉じる"
}
}
}

View File

@@ -95,13 +95,13 @@
"next": "다음 페이지"
},
"locale": {
"label": "Language",
"updateFailed": "Couldn't update language",
"label": "언어",
"updateFailed": "언어를 업데이트하지 못했습니다",
"mismatch": {
"message": "You're viewing this page in {{language}}.",
"switchToMine": "Switch to {{language}}",
"useThis": "Use {{language}} as my language",
"dismiss": "Dismiss"
"message": "{{language}}(으)로 이 페이지를 보고 있습니다.",
"switchToMine": "{{language}}(으)로 전환",
"useThis": "{{language}}을(를) 내 언어로 사용",
"dismiss": "닫기"
}
}
}

View File

@@ -95,13 +95,13 @@
"next": "Następna strona"
},
"locale": {
"label": "Language",
"updateFailed": "Couldn't update language",
"label": "Język",
"updateFailed": "Nie udało się zaktualizować języka",
"mismatch": {
"message": "You're viewing this page in {{language}}.",
"switchToMine": "Switch to {{language}}",
"useThis": "Use {{language}} as my language",
"dismiss": "Dismiss"
"message": "Przeglądasz tę stronę w języku: {{language}}.",
"switchToMine": "Przełącz na {{language}}",
"useThis": "Użyj {{language}} jako mojego języka",
"dismiss": "Zamknij"
}
}
}

View File

@@ -95,13 +95,13 @@
"next": "Página seguinte"
},
"locale": {
"label": "Language",
"updateFailed": "Couldn't update language",
"label": "Idioma",
"updateFailed": "Não foi possível atualizar o idioma",
"mismatch": {
"message": "You're viewing this page in {{language}}.",
"switchToMine": "Switch to {{language}}",
"useThis": "Use {{language}} as my language",
"dismiss": "Dismiss"
"message": "Está a ver esta página em {{language}}.",
"switchToMine": "Mudar para {{language}}",
"useThis": "Usar {{language}} como o meu idioma",
"dismiss": "Ignorar"
}
}
}

View File

@@ -95,13 +95,13 @@
"next": "Sonraki sayfa"
},
"locale": {
"label": "Language",
"updateFailed": "Couldn't update language",
"label": "Dil",
"updateFailed": "Dil güncellenemedi",
"mismatch": {
"message": "You're viewing this page in {{language}}.",
"switchToMine": "Switch to {{language}}",
"useThis": "Use {{language}} as my language",
"dismiss": "Dismiss"
"message": "Bu sayfayı {{language}} olarak görüntülüyorsunuz.",
"switchToMine": "{{language}} diline geç",
"useThis": "{{language}} dilini benim dilim olarak kullan",
"dismiss": "Kapat"
}
}
}

View File

@@ -95,13 +95,13 @@
"next": "Наступна сторінка"
},
"locale": {
"label": "Language",
"updateFailed": "Couldn't update language",
"label": "Мова",
"updateFailed": "Не вдалося оновити мову",
"mismatch": {
"message": "You're viewing this page in {{language}}.",
"switchToMine": "Switch to {{language}}",
"useThis": "Use {{language}} as my language",
"dismiss": "Dismiss"
"message": "Ви переглядаєте цю сторінку мовою {{language}}.",
"switchToMine": "Перейти на {{language}}",
"useThis": "Використовувати {{language}} як мою мову",
"dismiss": "Закрити"
}
}
}

View File

@@ -95,13 +95,13 @@
"next": "下一页"
},
"locale": {
"label": "Language",
"updateFailed": "Couldn't update language",
"label": "语言",
"updateFailed": "无法更新语言",
"mismatch": {
"message": "You're viewing this page in {{language}}.",
"switchToMine": "Switch to {{language}}",
"useThis": "Use {{language}} as my language",
"dismiss": "Dismiss"
"message": "您正在以{{language}}查看此页面。",
"switchToMine": "切换到{{language}}",
"useThis": "将{{language}}设为我的语言",
"dismiss": "关闭"
}
}
}

View File

@@ -22,13 +22,15 @@ import { GlobeIcon, XIcon } from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button";
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
import { Text } from "@probo/ui/src/v2/typography/Text";
import { useState } from "react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay";
import { DEFAULT_NAMESPACE } from "#/lib/i18n/backend";
import {
isUrlLocale,
URL_LOCALE_LABELS,
urlLocaleToLanguage,
} from "#/lib/i18n/locale";
import { useChangeLocale } from "#/lib/i18n/useChangeLocale";
import { useLocale } from "#/lib/i18n/useLocale";
@@ -49,16 +51,39 @@ interface LocaleMismatchCalloutProps {
// Full-bleed notice when the URL locale differs from the signed-in identity
// preference. Dismissed state is React-only (no localStorage/cookies).
export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProps) {
const { t } = useTranslation();
const { t, i18n } = useTranslation();
const identity = useFragment(localeMismatchCalloutFragment, identityKey);
const urlLocale = useLocale();
const [changeLocale, isChanging] = useChangeLocale();
const [updateLocale, isUpdating] = useUpdateLocale();
const [dismissed, setDismissed] = useState(false);
// Bumps after the identity-locale catalog loads so the switch button can
// re-render in that language (it may not be the active i18n language).
const [, setSavedCatalogTick] = useState(0);
const savedLocale = isUrlLocale(identity.locale) ? identity.locale : null;
const savedLanguage = savedLocale != null ? urlLocaleToLanguage(savedLocale) : null;
const visible = !dismissed && savedLocale != null && savedLocale !== urlLocale;
if (dismissed || savedLocale == null || savedLocale === urlLocale) {
useEffect(() => {
if (!visible || savedLanguage == null) {
return;
}
if (i18n.hasResourceBundle(savedLanguage, DEFAULT_NAMESPACE)) {
return;
}
let cancelled = false;
void i18n.loadLanguages(savedLanguage).then(() => {
if (!cancelled) {
setSavedCatalogTick(tick => tick + 1);
}
});
return () => {
cancelled = true;
};
}, [visible, savedLanguage, i18n]);
if (!visible || savedLocale == null || savedLanguage == null) {
return null;
}
@@ -105,7 +130,12 @@ export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProp
className="max-md:w-full"
onClick={switchToSaved}
>
{t("locale.mismatch.switchToMine", { language: savedLabel })}
{t("locale.mismatch.switchToMine", {
language: savedLabel,
// Label this action in the user's saved locale so it reads as
// "switch back to my language", not the page they're visiting.
lng: savedLanguage,
})}
</Button>
<Button
size={1}

View File

@@ -19,6 +19,7 @@
// SOFTWARE.
import { Menu } from "@base-ui/react/menu";
import { CheckIcon } from "@phosphor-icons/react";
import type { ComponentProps, ReactNode } from "react";
import { useDropdownContext } from "./context";
@@ -31,7 +32,8 @@ export type DropdownRadioItemProps
shortcut?: ReactNode;
};
// A menu item with a radio dot indicator. Used inside a DropdownRadioGroup.
// A menu item with a checkmark for the selected value. Used inside a
// DropdownRadioGroup (same indicator treatment as DropdownCheckboxItem).
export function DropdownRadioItem(props: DropdownRadioItemProps) {
const { className, shortcut, children, ...rest } = props;
const { size, variant, highContrast } = useDropdownContext();
@@ -42,7 +44,9 @@ export function DropdownRadioItem(props: DropdownRadioItemProps) {
{...rest}
>
<span className={dropdownItemIndicator()}>
<Menu.RadioItemIndicator className="size-1.5 rounded-full bg-current" />
<Menu.RadioItemIndicator>
<CheckIcon weight="bold" />
</Menu.RadioItemIndicator>
</span>
<span className={dropdownItemLabel()}>{children}</span>
{shortcut != null && <span className={dropdownItemShortcut()}>{shortcut}</span>}