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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -22,13 +22,15 @@ import { GlobeIcon, XIcon } from "@phosphor-icons/react";
import { Button } from "@probo/ui/src/v2/Button/Button"; import { Button } from "@probo/ui/src/v2/Button/Button";
import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton"; import { IconButton } from "@probo/ui/src/v2/IconButton/IconButton";
import { Text } from "@probo/ui/src/v2/typography/Text"; import { Text } from "@probo/ui/src/v2/typography/Text";
import { useState } from "react"; import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { graphql, useFragment } from "react-relay"; import { graphql, useFragment } from "react-relay";
import { DEFAULT_NAMESPACE } from "#/lib/i18n/backend";
import { import {
isUrlLocale, isUrlLocale,
URL_LOCALE_LABELS, URL_LOCALE_LABELS,
urlLocaleToLanguage,
} from "#/lib/i18n/locale"; } from "#/lib/i18n/locale";
import { useChangeLocale } from "#/lib/i18n/useChangeLocale"; import { useChangeLocale } from "#/lib/i18n/useChangeLocale";
import { useLocale } from "#/lib/i18n/useLocale"; 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 // Full-bleed notice when the URL locale differs from the signed-in identity
// preference. Dismissed state is React-only (no localStorage/cookies). // preference. Dismissed state is React-only (no localStorage/cookies).
export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProps) { export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProps) {
const { t } = useTranslation(); const { t, i18n } = useTranslation();
const identity = useFragment(localeMismatchCalloutFragment, identityKey); const identity = useFragment(localeMismatchCalloutFragment, identityKey);
const urlLocale = useLocale(); const urlLocale = useLocale();
const [changeLocale, isChanging] = useChangeLocale(); const [changeLocale, isChanging] = useChangeLocale();
const [updateLocale, isUpdating] = useUpdateLocale(); const [updateLocale, isUpdating] = useUpdateLocale();
const [dismissed, setDismissed] = useState(false); 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 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; return null;
} }
@@ -105,7 +130,12 @@ export function LocaleMismatchCallout({ identityKey }: LocaleMismatchCalloutProp
className="max-md:w-full" className="max-md:w-full"
onClick={switchToSaved} 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>
<Button <Button
size={1} size={1}

View File

@@ -19,6 +19,7 @@
// SOFTWARE. // SOFTWARE.
import { Menu } from "@base-ui/react/menu"; import { Menu } from "@base-ui/react/menu";
import { CheckIcon } from "@phosphor-icons/react";
import type { ComponentProps, ReactNode } from "react"; import type { ComponentProps, ReactNode } from "react";
import { useDropdownContext } from "./context"; import { useDropdownContext } from "./context";
@@ -31,7 +32,8 @@ export type DropdownRadioItemProps
shortcut?: ReactNode; 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) { export function DropdownRadioItem(props: DropdownRadioItemProps) {
const { className, shortcut, children, ...rest } = props; const { className, shortcut, children, ...rest } = props;
const { size, variant, highContrast } = useDropdownContext(); const { size, variant, highContrast } = useDropdownContext();
@@ -42,7 +44,9 @@ export function DropdownRadioItem(props: DropdownRadioItemProps) {
{...rest} {...rest}
> >
<span className={dropdownItemIndicator()}> <span className={dropdownItemIndicator()}>
<Menu.RadioItemIndicator className="size-1.5 rounded-full bg-current" /> <Menu.RadioItemIndicator>
<CheckIcon weight="bold" />
</Menu.RadioItemIndicator>
</span> </span>
<span className={dropdownItemLabel()}>{children}</span> <span className={dropdownItemLabel()}>{children}</span>
{shortcut != null && <span className={dropdownItemShortcut()}>{shortcut}</span>} {shortcut != null && <span className={dropdownItemShortcut()}>{shortcut}</span>}