From 62ae123b260805d2c3ce5a9397f227c1db5c9c03 Mon Sep 17 00:00:00 2001 From: iNerdStack Date: Tue, 20 Oct 2026 07:00:24 +0100 Subject: [PATCH 1/4] feat: translatable UI labels for LanguageSwitcher, PhoneInput, NavBar and TranslationWidget (#30) --- .../docs/components/inputs/phone-input.mdx | 46 ++++++++++ .../language-switchers/language-switcher.mdx | 41 +++++++++ content/docs/components/navigation/navbar.mdx | 35 ++++++++ .../translation/translation-widget.mdx | 34 ++++++++ content/docs/customization/interfaces.mdx | 85 +++++++++++++++++++ src/registry/components/index.ts | 16 +++- src/registry/components/language-switcher.tsx | 49 ++++++++--- src/registry/components/navbar.tsx | 33 +++++-- src/registry/components/phone-input.tsx | 67 ++++++++++++--- .../components/translation-widget.tsx | 39 +++++++-- test/language-switcher.test.tsx | 55 ++++++++++++ test/navbar.test.tsx | 26 ++++++ test/phone-input.test.tsx | 52 ++++++++++++ test/translation-widget.test.tsx | 39 +++++++++ 14 files changed, 582 insertions(+), 35 deletions(-) diff --git a/content/docs/components/inputs/phone-input.mdx b/content/docs/components/inputs/phone-input.mdx index ea19e32..1678179 100644 --- a/content/docs/components/inputs/phone-input.mdx +++ b/content/docs/components/inputs/phone-input.mdx @@ -128,6 +128,8 @@ export function PhoneField() { | `side` | `"top" \| "bottom"` | No | Preferred menu side; flips when there is no room (default: `"bottom"`) | | `searchPlaceholder` | `string` | No | Placeholder for the country search (default: `"Search countries..."`) | | `emptyMessage` | `ReactNode` | No | Shown when the search matches nothing (default: `"No countries found."`) | +| `labels` | `Partial` | No | Override the built-in English UI text (see below) | +| `displayLocale` | `string` | No | Locale for country names, e.g. `"fr"` (default: English names) | | `maxHeight` | `number \| string` | No | Max height of the scrolling country list (default: `"15rem"`) | | `label` | `string` | No | Accessible label for the input | | `className` | `string` | No | Classes for the root wrapper | @@ -256,6 +258,50 @@ Every part takes its own classes through `classNames`, merged after the defaults /> ``` +### Translated UI text + +Pass `labels` to translate the country menu's text, and `displayLocale` to show +country names in another language. Names come from the browser's +[`Intl.DisplayNames`](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DisplayNames), +so their exact wording can vary slightly between browsers. Searching matches the +translated name, while `onValueChange` still reports the country's English `name`. +The `searchPlaceholder` and `emptyMessage` props still work, and win over `labels` +when both are set. + +```tsx +"use client"; + +import { PhoneInput } from "@/components/ui/phone-input"; + +export default function App() { + return ( + + ); +} +``` + +| Key | Default | Used for | +| ------------------- | ----------------------- | ----------------------------- | +| `trigger` | `"Select country"` | Country trigger label | +| `searchPlaceholder` | `"Search countries..."` | Search field placeholder | +| `searchLabel` | `"Search countries"` | Search field accessible label | +| `clearSearch` | `"Clear search"` | Clear button accessible label | +| `emptyMessage` | `"No countries found."` | Shown when nothing matches | +| `listbox` | `"Select country"` | Country list accessible label | + ## Keyboard support The country menu follows the WAI-ARIA combobox pattern: focus stays in the search diff --git a/content/docs/components/language-switchers/language-switcher.mdx b/content/docs/components/language-switchers/language-switcher.mdx index 6d67bf0..58ada9b 100644 --- a/content/docs/components/language-switchers/language-switcher.mdx +++ b/content/docs/components/language-switchers/language-switcher.mdx @@ -130,6 +130,7 @@ export function LocaleSwitcher() { | `searchable` | `boolean` | No | Show the search field (default: `true`) | | `searchPlaceholder` | `string` | No | Search placeholder (default: `"Search languages..."`) | | `emptyMessage` | `ReactNode` | No | Shown when the search matches nothing (default: `"No languages found."`) | +| `labels` | `Partial` | No | Override the built-in English UI text (see below) | | `disabled` | `boolean` | No | Disable the trigger | | `renderOption` | `(locale, state) => ReactNode` | No | Replace each option row's content; `state` has `selected`, `active`, `label`, `flag` | | `classNames` | `LanguageSwitcherClassNames` | No | Per-part classes: `trigger`, `content`, `search`, `list`, `item`, `empty` | @@ -267,6 +268,46 @@ The switcher is styled with shadcn theme tokens, so it follows your theme. Use /> ``` +### Translated UI text + +The search placeholder, empty state and accessible labels default to English. Pass +`labels` to translate them — any key you leave out keeps its English default. The +`searchPlaceholder` and `emptyMessage` props still work, and win over `labels` when +both are set. + +```tsx +"use client"; + +import { LanguageSwitcher } from "@/components/ui/language-switcher"; + +export default function App() { + return ( + `Langue actuelle : ${name}`, + }} + /> + ); +} +``` + +| Key | Default | Used for | +| ------------------- | --------------------------------------- | ----------------------------- | +| `searchPlaceholder` | `"Search languages..."` | Search field placeholder | +| `searchLabel` | `"Search languages"` | Search field accessible label | +| `clearSearch` | `"Clear search"` | Clear button accessible label | +| `emptyMessage` | `"No languages found."` | Shown when nothing matches | +| `listbox` | `"Select language"` | Option list accessible label | +| `current` | `(name) => "Current language: " + name` | Trigger accessible label | + ## Keyboard support The popup follows the WAI-ARIA combobox pattern: focus stays in the search field, diff --git a/content/docs/components/navigation/navbar.mdx b/content/docs/components/navigation/navbar.mdx index ce8bfb1..f3a6a56 100644 --- a/content/docs/components/navigation/navbar.mdx +++ b/content/docs/components/navigation/navbar.mdx @@ -137,6 +137,7 @@ export function SiteNav() { | `showFlags` | `boolean` | No | Show flags in language selector (default: `true`) | | `maxHeight` | `number \| string` | No | Max height of the scrolling language menu (default: `"15rem"`) | | `linkComponent` | `React.ElementType` | No | Component used for internal links (default: `"a"`) | +| `labels` | `Partial` | No | Override the built-in English UI text (see below) | | `classNames` | `NavBarClassNames` | No | Per-part class overrides (see below) | | `className` | `string` | No | Additional CSS classes | @@ -361,6 +362,40 @@ export default function App() { } ``` +### Translated UI text + +The language menu, mobile menu button and GitHub link carry English accessible +labels by default. Pass `labels` to translate them — any key you leave out keeps +its English default. + +```tsx +"use client"; + +import { NavBar } from "@/components/ui/navbar"; + +export default function App() { + return ( + MyApp} + locales={[{ code: "en" }, { code: "fr" }]} + defaultValue="fr" + labels={{ + listbox: "Choisir la langue", + current: (name) => `Langue actuelle : ${name}`, + toggleMenu: "Ouvrir le menu", + }} + /> + ); +} +``` + +| Key | Default | Used for | +| ------------ | --------------------------------------- | --------------------------------- | +| `listbox` | `"Select language"` | Language menu accessible label | +| `current` | `(name) => "Current language: " + name` | Language trigger accessible label | +| `toggleMenu` | `"Toggle menu"` | Mobile menu button label | +| `github` | `"GitHub"` | GitHub icon link label | + ## Keyboard support The language menu follows the WAI-ARIA listbox pattern. With no text field in the diff --git a/content/docs/components/translation/translation-widget.mdx b/content/docs/components/translation/translation-widget.mdx index 21db86b..a1179c5 100644 --- a/content/docs/components/translation/translation-widget.mdx +++ b/content/docs/components/translation/translation-widget.mdx @@ -170,6 +170,7 @@ export function Translate() { | `showClear` | `boolean` | No | Show a clear button under an editable source (default: `true`) | | `showCopy` | `boolean` | No | Show a copy-to-clipboard button under the target (default: `true`) | | `onTargetCopy` | `(text: string) => void` | No | Called with the target text after it is copied | +| `labels` | `TranslationWidgetLabels` | No | Override any subset of the built-in English UI text (see below) | | `loading` | `boolean` | No | Show a skeleton in the target pane and mark it `aria-busy` | | `classNames` | `TranslationWidgetClassNames` | No | Per-part class overrides: `root`, `header`, `pane`, `textarea`, `footer`, `swap`, `chip` | | `dir` | `"ltr" \| "rtl"` | No | Override the auto-detected layout direction | @@ -293,6 +294,39 @@ async function translate(text: string) { ; ``` +## Translated UI text + +The pane placeholders and labels have their own props (`sourcePlaceholder`, +`sourceLabel` and so on). The rest of the built-in text — toolbar buttons, the copy +announcement and the word read after the character count — comes from `labels`. +Any key you leave out keeps its English default. + +```tsx + (count === 1 ? "caractère" : "caractères"), + }} +/> +``` + +| Key | Default | Used for | +| -------------- | ----------------------------------- | ------------------------------------- | +| `swap` | `"Swap languages"` | Swap button accessible label | +| `copy` | `"Copy translation"` | Copy button accessible label | +| `copied` | `"Copied"` | Copy button label right after a copy | +| `copiedStatus` | `"Translation copied to clipboard"` | Screen reader announcement after copy | +| `clearSource` | `"Clear source text"` | Clear button accessible label | +| `characters` | `(count) => "characters"` | Read after the character count | + ## Customization Every part takes extra classes through `classNames`, merged after the defaults with diff --git a/content/docs/customization/interfaces.mdx b/content/docs/customization/interfaces.mdx index d7931d4..adbd735 100644 --- a/content/docs/customization/interfaces.mdx +++ b/content/docs/customization/interfaces.mdx @@ -65,11 +65,35 @@ export interface LanguageSwitcherProps { showFlags?: boolean; /** Display labels in native language or English (default: "english") */ label?: "native" | "english"; + /** Override the built-in English UI text — any key you omit keeps its default */ + labels?: Partial; /** Additional CSS classes */ className?: string; } ``` +### LanguageSwitcherLabels + +```ts +export interface LanguageSwitcherLabels { + /** Placeholder for the search field (default: "Search languages...") */ + searchPlaceholder: string; + /** Accessible label for the search field (default: "Search languages") */ + searchLabel: string; + /** Accessible label for the button that clears the search (default: "Clear search") */ + clearSearch: string; + /** Content shown when the search matches nothing (default: "No languages found.") */ + emptyMessage: React.ReactNode; + /** Accessible label for the option list (default: "Select language") */ + listbox: string; + /** Accessible label for the trigger (default: "Current language: {name}") */ + current: (name: string) => string; +} +``` + +Pass any subset as `labels` — keys you omit keep their English default. The same +holds for every `*Labels` interface below. + ## Phone Input ### Country @@ -109,6 +133,29 @@ export interface PhoneInputProps { showFlags?: boolean; /** Accessible label for the input */ label?: string; + /** Override the built-in English UI text — any key you omit keeps its default */ + labels?: Partial; + /** BCP 47 locale used to display country names, e.g. "fr" (default: English names) */ + displayLocale?: string; +} +``` + +### PhoneInputLabels + +```ts +export interface PhoneInputLabels { + /** Accessible label for the country trigger (default: "Select country") */ + trigger: string; + /** Placeholder for the country search field (default: "Search countries...") */ + searchPlaceholder: string; + /** Accessible label for the country search field (default: "Search countries") */ + searchLabel: string; + /** Accessible label for the button that clears the search (default: "Clear search") */ + clearSearch: string; + /** Content shown when the search matches nothing (default: "No countries found.") */ + emptyMessage: React.ReactNode; + /** Accessible label for the country list (default: "Select country") */ + listbox: string; } ``` @@ -159,11 +206,49 @@ export interface NavBarProps { sticky?: boolean; /** Show flags in language selector (default: true) */ showFlags?: boolean; + /** Override the built-in English UI text — any key you omit keeps its default */ + labels?: Partial; /** Additional CSS classes */ className?: string; } ``` +### NavBarLabels + +```ts +export interface NavBarLabels { + /** Accessible label for the language list (default: "Select language") */ + listbox: string; + /** Accessible label for the language menu triggers (default: "Current language: {name}") */ + current: (name: string) => string; + /** Accessible label for the mobile menu button (default: "Toggle menu") */ + toggleMenu: string; + /** Accessible label for the GitHub icon link (default: "GitHub") */ + github: string; +} +``` + +## Translation Widget + +### TranslationWidgetLabels + +```ts +export interface TranslationWidgetLabels { + /** Accessible label for the swap button (default: "Swap languages") */ + swap: string; + /** Accessible label for the copy button (default: "Copy translation") */ + copy: string; + /** Accessible label for the copy button right after a copy (default: "Copied") */ + copied: string; + /** Screen reader announcement after a copy (default: "Translation copied to clipboard") */ + copiedStatus: string; + /** Accessible label for the clear button (default: "Clear source text") */ + clearSource: string; + /** Screen reader word read after the character count (default: "characters") */ + characters: (count: number) => string; +} +``` + ## Extending Interfaces Because Babelize Elements components are fully owned by you once installed, you can extend these interfaces directly in the component files. For example, to add a custom `meta` field to each locale, add it to the `Locale` interface in your local copy: diff --git a/src/registry/components/index.ts b/src/registry/components/index.ts index 30f91f2..d31a9de 100644 --- a/src/registry/components/index.ts +++ b/src/registry/components/index.ts @@ -6,6 +6,7 @@ export { LanguageSwitcher } from "./language-switcher"; export type { LanguageSwitcherProps, LanguageSwitcherClassNames, + LanguageSwitcherLabels, LanguageSwitcherOptionState, } from "./language-switcher"; @@ -16,10 +17,19 @@ export type { } from "./language-switcher-pill"; export { PhoneInput, COUNTRIES } from "./phone-input"; -export type { PhoneInputProps, PhoneInputClassNames, Country } from "./phone-input"; +export type { + PhoneInputProps, + PhoneInputClassNames, + PhoneInputLabels, + Country, +} from "./phone-input"; export { NavBar } from "./navbar"; -export type { NavBarProps, NavBarClassNames, NavLink } from "./navbar"; +export type { NavBarProps, NavBarClassNames, NavBarLabels, NavLink } from "./navbar"; export { TranslationWidget } from "./translation-widget"; -export type { TranslationWidgetProps, TranslationWidgetClassNames } from "./translation-widget"; +export type { + TranslationWidgetProps, + TranslationWidgetClassNames, + TranslationWidgetLabels, +} from "./translation-widget"; diff --git a/src/registry/components/language-switcher.tsx b/src/registry/components/language-switcher.tsx index 4bf68c2..f9171ad 100644 --- a/src/registry/components/language-switcher.tsx +++ b/src/registry/components/language-switcher.tsx @@ -17,6 +17,31 @@ import { getFlag, getLanguageName, isRtl } from "./locale-data"; export type { Locale }; +/** Built-in UI text. Every key is optional in `labels`; omitted keys keep the English default. */ +export interface LanguageSwitcherLabels { + /** Placeholder for the search field (default: "Search languages...") */ + searchPlaceholder: string; + /** Accessible label for the search field (default: "Search languages") */ + searchLabel: string; + /** Accessible label for the button that clears the search (default: "Clear search") */ + clearSearch: string; + /** Content shown when the search matches nothing (default: "No languages found.") */ + emptyMessage: React.ReactNode; + /** Accessible label for the option list (default: "Select language") */ + listbox: string; + /** Accessible label for the trigger (default: "Current language: {name}") */ + current: (name: string) => string; +} + +const DEFAULT_LABELS: LanguageSwitcherLabels = { + searchPlaceholder: "Search languages...", + searchLabel: "Search languages", + clearSearch: "Clear search", + emptyMessage: "No languages found.", + listbox: "Select language", + current: (name) => `Current language: ${name}`, +}; + /** Per-part class overrides, merged after the defaults with `cn`. */ export interface LanguageSwitcherClassNames { /** The button that opens the menu */ @@ -78,10 +103,12 @@ export interface LanguageSwitcherProps extends Omit< side?: "top" | "bottom"; /** Show the search field (default: true) */ searchable?: boolean; - /** Placeholder for the search field (default: "Search languages...") */ + /** Placeholder for the search field. Alias of `labels.searchPlaceholder`, wins over it */ searchPlaceholder?: string; - /** Content shown when the search matches nothing (default: "No languages found.") */ + /** Empty-search content. Alias of `labels.emptyMessage`, wins over it */ emptyMessage?: React.ReactNode; + /** Override the built-in English UI text — any key you omit keeps its default */ + labels?: Partial; /** Disable the trigger */ disabled?: boolean; /** Replace the content of each option row. The row keeps its button, role and state. */ @@ -141,8 +168,9 @@ export const LanguageSwitcher = React.forwardRef ref.current as HTMLDivElement); + const text = { ...DEFAULT_LABELS, ...labels }; const resolveLabel = (l: Locale) => l.label ?? getLanguageName(l.code, labelMode) ?? l.code; const activeLocale = locales.find((l) => l.code === locale); const activeLabel = activeLocale ? resolveLabel(activeLocale) : locale; @@ -259,7 +288,7 @@ export const LanguageSwitcher = React.forwardRef {showFlags ? ( @@ -300,8 +329,8 @@ export const LanguageSwitcher = React.forwardRef { setSearch(""); nav.resetActive(); @@ -335,7 +364,7 @@ export const LanguageSwitcher = React.forwardRef {filtered.map((l, index) => { @@ -396,7 +425,7 @@ export const LanguageSwitcher = React.forwardRef - {emptyMessage} + {emptyMessage ?? text.emptyMessage} )} diff --git a/src/registry/components/navbar.tsx b/src/registry/components/navbar.tsx index e64f3d6..c1999aa 100644 --- a/src/registry/components/navbar.tsx +++ b/src/registry/components/navbar.tsx @@ -24,6 +24,25 @@ export interface NavLink { href: string; } +/** Built-in UI text. Every key is optional in `labels`; omitted keys keep the English default. */ +export interface NavBarLabels { + /** Accessible label for the language list (default: "Select language") */ + listbox: string; + /** Accessible label for the language menu triggers (default: "Current language: {name}") */ + current: (name: string) => string; + /** Accessible label for the mobile menu button (default: "Toggle menu") */ + toggleMenu: string; + /** Accessible label for the GitHub icon link (default: "GitHub") */ + github: string; +} + +const DEFAULT_LABELS: NavBarLabels = { + listbox: "Select language", + current: (name) => `Current language: ${name}`, + toggleMenu: "Toggle menu", + github: "GitHub", +}; + /** Per-part class overrides, merged after the defaults with `cn`. */ export interface NavBarClassNames { /** The `